feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { db, users, eq } from '~/drizzle/db'
|
||||
import { SmtpService } from '~~/server/services/smtpService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import {
|
||||
delStoreIfValue,
|
||||
getStore,
|
||||
hashStateCode,
|
||||
parseStoreJson,
|
||||
setStore
|
||||
} from '~~/server/utils/captchaStore'
|
||||
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { randomInt } from 'crypto'
|
||||
import { getServerTimestamp } from '~~/server/utils/serverTime'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<Record<string, unknown> | null>(event)
|
||||
const reqUserId = body?.userId
|
||||
const rawToken = typeof body?.token === 'string' ? body.token : ''
|
||||
const email = typeof body?.email === 'string' ? body.email : ''
|
||||
const token = rawToken || getCookie(event, 'pre-auth-token')
|
||||
|
||||
// 必须提供预认证令牌
|
||||
if (!token) {
|
||||
throw createApiError(400, 'AUTH_MISSING_PRE_TOKEN', '缺少预认证令牌')
|
||||
}
|
||||
|
||||
let userId: number
|
||||
let preAuthPayload: { tokenVersion?: unknown } | null = null
|
||||
try {
|
||||
const decoded = JWTEnhanced.verify(token) as any
|
||||
if (decoded.type !== 'pre-auth' || decoded.scope !== '2fa_pending') {
|
||||
throw new Error('无效的预认证令牌')
|
||||
}
|
||||
userId = decoded.userId
|
||||
preAuthPayload = decoded
|
||||
} catch (e) {
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
|
||||
}
|
||||
|
||||
// 校验 userId 是否匹配
|
||||
if (!reqUserId || Number(reqUserId) !== userId) {
|
||||
throw createApiError(403, 'AUTH_USER_ID_MISMATCH', '非法操作:用户ID不匹配')
|
||||
}
|
||||
|
||||
// 获取用户邮箱
|
||||
const userResult = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
emailVerified: users.emailVerified,
|
||||
name: users.name,
|
||||
tokenVersion: users.tokenVersion
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
|
||||
const user = userResult[0]
|
||||
// 增加 emailVerified 校验
|
||||
if (!user || !user.email || !user.emailVerified) {
|
||||
throw createApiError(400, 'AUTH_USER_NOT_FOUND_OR_NO_EMAIL', '用户不存在或未绑定邮箱')
|
||||
}
|
||||
|
||||
if (!JWTEnhanced.hasCurrentTokenVersion(preAuthPayload, user.tokenVersion)) {
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
deleteCookie(event, 'binding-token')
|
||||
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
|
||||
}
|
||||
|
||||
// 校验用户输入的邮箱是否匹配
|
||||
if (!email || email.trim().toLowerCase() !== user.email.toLowerCase()) {
|
||||
throw createApiError(400, 'AUTH_EMAIL_MISMATCH', '邮箱地址不匹配')
|
||||
}
|
||||
|
||||
// 检查是否在冷却时间内
|
||||
const stateKey = `2fa-email:${userId}`
|
||||
const existingRaw = await getStore(stateKey)
|
||||
const existingCode = parseStoreJson<{ expiresAt: number }>(existingRaw)
|
||||
const now = getServerTimestamp()
|
||||
if (existingCode && Number.isFinite(existingCode.expiresAt) && existingCode.expiresAt > now) {
|
||||
// 5 * 60 * 1000 = 300000ms
|
||||
const totalDuration = 5 * 60 * 1000
|
||||
const timePassed = totalDuration - (existingCode.expiresAt - now)
|
||||
|
||||
if (timePassed < 60 * 1000) {
|
||||
// 60秒冷却
|
||||
const remainingSeconds = Math.ceil((60000 - timePassed) / 1000)
|
||||
throw createApiError(
|
||||
429,
|
||||
'AUTH_RATE_LIMITED_SECONDS',
|
||||
`操作过于频繁,请等待 ${remainingSeconds} 秒后再试`,
|
||||
{ params: [remainingSeconds] }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const code = randomInt(100000, 1000000).toString()
|
||||
const expiresAt = now + 5 * 60 * 1000
|
||||
const storedValue = JSON.stringify({
|
||||
codeHash: hashStateCode(stateKey, code),
|
||||
expiresAt
|
||||
})
|
||||
await setStore(stateKey, storedValue, 5 * 60)
|
||||
|
||||
const clientIP = getClientIP(event)
|
||||
|
||||
// 发送邮件
|
||||
const smtp = SmtpService.getInstance()
|
||||
await smtp.initializeSmtpConfig()
|
||||
|
||||
let sent = false
|
||||
try {
|
||||
sent = await smtp.renderAndSend(
|
||||
user.email,
|
||||
'verification.code',
|
||||
{
|
||||
name: user.name || '用户',
|
||||
email: user.email,
|
||||
code,
|
||||
expiresInMinutes: 5,
|
||||
action: '登录验证'
|
||||
},
|
||||
clientIP
|
||||
)
|
||||
} catch (error) {
|
||||
await delStoreIfValue(stateKey, storedValue)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!sent) {
|
||||
await delStoreIfValue(stateKey, storedValue)
|
||||
throw createApiError(500, 'AUTH_CODE_SEND_FAILED', '验证码发送失败')
|
||||
}
|
||||
|
||||
return { success: true, message: '验证码已发送至您的邮箱' }
|
||||
})
|
||||
@@ -0,0 +1,292 @@
|
||||
import { db, userIdentities, eq, and, users, systemSettings } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/auth-session'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { getServerTimestamp } from '~~/server/utils/serverTime'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { verifyBindingToken } from '~~/server/utils/oauth-token'
|
||||
import { isSecureRequest } from '~~/server/utils/request-utils'
|
||||
import {
|
||||
delStore,
|
||||
delStoreIfValue,
|
||||
getStore,
|
||||
incrStore,
|
||||
parseStoreJson,
|
||||
verifyStateCode
|
||||
} from '~~/server/utils/captchaStore'
|
||||
import otplib from 'otplib'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import {
|
||||
computeRequirePasswordChange,
|
||||
resolveRequirePasswordChange
|
||||
} from '~~/server/utils/system-settings-helper'
|
||||
import { getPasswordSetupState } from '~~/server/utils/initial-password-policy'
|
||||
import { canBindOAuthIdentity } from '~~/server/utils/auth-route-policy'
|
||||
import { syncOAuthIdentityAvatar } from '~~/server/utils/oauth-identity'
|
||||
|
||||
const { authenticator } = otplib
|
||||
const TOTP_FAILURE_LIMIT = 5
|
||||
const TOTP_FAILURE_WINDOW_SECONDS = 5 * 60
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<Record<string, unknown> | null>(event)
|
||||
const code = typeof body?.code === 'string' ? body.code : ''
|
||||
const type = typeof body?.type === 'string' ? body.type : ''
|
||||
const bodyToken = typeof body?.token === 'string' ? body.token : ''
|
||||
const token = bodyToken || getCookie(event, 'pre-auth-token')
|
||||
|
||||
if (!code || !type) {
|
||||
throw createApiError(400, 'AUTH_MISSING_REQUIRED_PARAMS', '缺少必要参数')
|
||||
}
|
||||
|
||||
// 验证预认证临时令牌
|
||||
let targetUserId: number
|
||||
let preAuthPayload: { tokenVersion?: unknown } | null = null
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = JWTEnhanced.verify(token) as any
|
||||
if (decoded.type !== 'pre-auth' || decoded.scope !== '2fa_pending') {
|
||||
throw new Error('无效的预认证令牌')
|
||||
}
|
||||
targetUserId = decoded.userId
|
||||
preAuthPayload = decoded
|
||||
} catch (e) {
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
|
||||
}
|
||||
} else {
|
||||
// 强制要求 Token
|
||||
throw createApiError(400, 'AUTH_MISSING_PRE_TOKEN_RELOGIN', '缺少预认证令牌,请重新登录')
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const userResult = await db.select().from(users).where(eq(users.id, targetUserId)).limit(1)
|
||||
const user = userResult[0]
|
||||
if (!user) {
|
||||
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
|
||||
}
|
||||
|
||||
if (!JWTEnhanced.hasCurrentTokenVersion(preAuthPayload, user.tokenVersion)) {
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
deleteCookie(event, 'binding-token')
|
||||
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
|
||||
}
|
||||
|
||||
if (user.status !== 'active') {
|
||||
throw createApiError(403, 'AUTH_ACCOUNT_DISABLED_OR_RESTRICTED', '账号已被禁用或限制访问')
|
||||
}
|
||||
|
||||
const clientIp = getClientIP(event)
|
||||
const totpUserFailureKey = `2fa_totp_user:${targetUserId}`
|
||||
const totpIpFailureKey = `2fa_totp_ip:${clientIp}`
|
||||
|
||||
if (type === 'totp') {
|
||||
const identity = await db.query.userIdentities.findFirst({
|
||||
where: and(eq(userIdentities.userId, targetUserId), eq(userIdentities.provider, 'totp'))
|
||||
})
|
||||
if (!identity) {
|
||||
throw createApiError(400, 'AUTH_TOTP_NOT_ENABLED', '未开启TOTP验证')
|
||||
}
|
||||
|
||||
// 先原子占用一次验证额度,防止并发请求同时绕过失败次数限制。
|
||||
const [userFailureCount, ipFailureCount] = await Promise.all([
|
||||
incrStore(totpUserFailureKey, TOTP_FAILURE_WINDOW_SECONDS),
|
||||
incrStore(totpIpFailureKey, TOTP_FAILURE_WINDOW_SECONDS)
|
||||
])
|
||||
|
||||
if (userFailureCount > TOTP_FAILURE_LIMIT || ipFailureCount > TOTP_FAILURE_LIMIT) {
|
||||
throw createApiError(
|
||||
429,
|
||||
'AUTH_TOTP_TOO_MANY_ATTEMPTS',
|
||||
'动态验证码错误次数过多,请在 5 分钟后重试'
|
||||
)
|
||||
}
|
||||
|
||||
const totpValid = authenticator.check(code, identity.providerUserId)
|
||||
|
||||
if (!totpValid) {
|
||||
throw createApiError(400, 'AUTH_TOTP_CODE_INVALID', '动态验证码错误')
|
||||
}
|
||||
|
||||
await delStore(totpUserFailureKey)
|
||||
await delStore(totpIpFailureKey)
|
||||
} else if (type === 'email') {
|
||||
const stateKey = `2fa-email:${targetUserId}`
|
||||
const storedRaw = await getStore(stateKey)
|
||||
const stored = parseStoreJson<{
|
||||
codeHash: string
|
||||
expiresAt: number
|
||||
}>(storedRaw)
|
||||
|
||||
if (!stored || typeof stored.codeHash !== 'string' || !Number.isFinite(stored.expiresAt)) {
|
||||
if (storedRaw) await delStoreIfValue(stateKey, storedRaw)
|
||||
throw createApiError(400, 'AUTH_CODE_EXPIRED_OR_MISSING', '验证码已过期或不存在')
|
||||
}
|
||||
|
||||
const now = getServerTimestamp()
|
||||
if (stored.expiresAt <= now) {
|
||||
await delStoreIfValue(stateKey, storedRaw!)
|
||||
throw createApiError(400, 'AUTH_CODE_EXPIRED', '验证码已过期')
|
||||
}
|
||||
|
||||
const remainingTtl = Math.max(1, Math.ceil((stored.expiresAt - now) / 1000))
|
||||
const attemptKey = `${stateKey}:attempts:${stored.codeHash}`
|
||||
const attemptCount = await incrStore(attemptKey, remainingTtl)
|
||||
|
||||
if (attemptCount > 5) {
|
||||
await delStoreIfValue(stateKey, storedRaw!)
|
||||
await delStore(attemptKey)
|
||||
throw createApiError(400, 'AUTH_TOO_MANY_VERIFY_ATTEMPTS', '验证尝试次数过多,请重新获取')
|
||||
}
|
||||
|
||||
if (verifyStateCode(stateKey, String(code), stored.codeHash)) {
|
||||
if (!(await delStoreIfValue(stateKey, storedRaw!))) {
|
||||
throw createApiError(400, 'AUTH_CODE_ALREADY_USED', '验证码已使用,请重新获取')
|
||||
}
|
||||
await delStore(attemptKey)
|
||||
} else {
|
||||
if (attemptCount >= 5) {
|
||||
await delStoreIfValue(stateKey, storedRaw!)
|
||||
await delStore(attemptKey)
|
||||
throw createApiError(400, 'AUTH_TOO_MANY_VERIFY_ATTEMPTS', '验证尝试次数过多,请重新获取')
|
||||
}
|
||||
throw createApiError(
|
||||
400,
|
||||
'AUTH_CODE_WRONG_ATTEMPTS_LEFT',
|
||||
`验证码错误,剩余尝试次数:${5 - attemptCount}`,
|
||||
{ params: [5 - attemptCount] }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw createApiError(400, 'AUTH_UNSUPPORTED_VERIFICATION_TYPE', '不支持的验证类型')
|
||||
}
|
||||
|
||||
// 验证通过,更新登录信息
|
||||
const bindingToken = getCookie(event, 'binding-token')
|
||||
if (bindingToken) {
|
||||
let bindingPayload
|
||||
try {
|
||||
bindingPayload = verifyBindingToken(bindingToken)
|
||||
} catch (e) {
|
||||
deleteCookie(event, 'binding-token')
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
throw createApiError(400, 'AUTH_BINDING_SESSION_INVALID', '绑定会话已失效,请重新发起绑定')
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const [currentUser] = await tx
|
||||
.select({
|
||||
id: users.id,
|
||||
tokenVersion: users.tokenVersion,
|
||||
forcePasswordChange: users.forcePasswordChange,
|
||||
passwordChangedAt: users.passwordChangedAt,
|
||||
avatarProvider: users.avatarProvider,
|
||||
avatarProviderUserId: users.avatarProviderUserId
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.for('update')
|
||||
|
||||
if (
|
||||
!currentUser ||
|
||||
!JWTEnhanced.hasCurrentTokenVersion(preAuthPayload, currentUser.tokenVersion)
|
||||
) {
|
||||
deleteCookie(event, 'binding-token')
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
|
||||
}
|
||||
|
||||
const [currentSettings] = await tx
|
||||
.select({
|
||||
forcePasswordChangeOnFirstLogin: systemSettings.forcePasswordChangeOnFirstLogin
|
||||
})
|
||||
.from(systemSettings)
|
||||
.limit(1)
|
||||
.for('share')
|
||||
const requirePasswordChange = computeRequirePasswordChange(
|
||||
currentUser,
|
||||
currentSettings?.forcePasswordChangeOnFirstLogin ?? false
|
||||
)
|
||||
|
||||
if (!canBindOAuthIdentity(requirePasswordChange)) {
|
||||
deleteCookie(event, 'binding-token')
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
throw createApiError(
|
||||
403,
|
||||
'AUTH_PASSWORD_CHANGE_REQUIRED',
|
||||
'请先完成密码修改后再绑定第三方账号',
|
||||
{ requirePasswordChange: true }
|
||||
)
|
||||
}
|
||||
|
||||
const existing = await tx.query.userIdentities.findFirst({
|
||||
where: (t, { eq, and }) =>
|
||||
and(
|
||||
eq(t.provider, bindingPayload.provider),
|
||||
eq(t.providerUserId, bindingPayload.providerUserId)
|
||||
)
|
||||
})
|
||||
|
||||
if (existing && existing.userId !== user.id) {
|
||||
throw createApiError(409, 'AUTH_OAUTH_BOUND_OTHER_USER', '该第三方账号已被其他用户绑定')
|
||||
}
|
||||
|
||||
await syncOAuthIdentityAvatar(tx, currentUser, existing, {
|
||||
provider: bindingPayload.provider,
|
||||
providerUserId: bindingPayload.providerUserId,
|
||||
providerUsername: bindingPayload.providerUsername,
|
||||
avatar: bindingPayload.avatar
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
lastLogin: getBeijingTime(),
|
||||
lastLoginIp: clientIp
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
.catch((err) => console.error('Error updating user login info:', err))
|
||||
|
||||
// 生成Token
|
||||
const { token: authToken } = await createAuthSession(event, user, '2fa')
|
||||
|
||||
const isSecure = isSecureRequest(event)
|
||||
|
||||
setCookie(event, 'auth-token', authToken, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
deleteCookie(event, 'binding-token')
|
||||
|
||||
const requirePasswordChange = await resolveRequirePasswordChange(user)
|
||||
const passwordSetupState = getPasswordSetupState(user, requirePasswordChange)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
grade: user.grade,
|
||||
class: user.class,
|
||||
role: user.role,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
forcePasswordChange: user.forcePasswordChange,
|
||||
passwordChangedAt: user.passwordChangedAt,
|
||||
requirePasswordChange,
|
||||
// 兼容旧客户端字段名
|
||||
needsPasswordChange: requirePasswordChange,
|
||||
...passwordSetupState,
|
||||
has2FA: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,456 @@
|
||||
import {
|
||||
decodeOAuthStateCookie,
|
||||
getOAuthStateCookieNames,
|
||||
LEGACY_OAUTH_STATE_COOKIE_NAMES,
|
||||
parseState,
|
||||
getRedirectUri,
|
||||
getSafeOAuthReturnPath,
|
||||
verifyCompactOAuthState
|
||||
} from '~~/server/utils/oauth'
|
||||
import { generateBindingToken } from '~~/server/utils/oauth-token'
|
||||
import { db, eq, users, systemSettings } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/auth-session'
|
||||
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
|
||||
import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService'
|
||||
import {
|
||||
getOAuthBaseConfig,
|
||||
getProviderRuntimeConfig,
|
||||
isOAuthProviderEnabled,
|
||||
isSupportedOAuthProvider
|
||||
} from '~~/server/services/oauthConfigService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import type { H3Event } from 'h3'
|
||||
import { getRequestOrigin, isSecureRequest } from '~~/server/utils/request-utils'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { computeRequirePasswordChange } from '~~/server/utils/system-settings-helper'
|
||||
import { canBindOAuthIdentity } from '~~/server/utils/auth-route-policy'
|
||||
import { syncOAuthIdentityAvatar } from '~~/server/utils/oauth-identity'
|
||||
|
||||
const getSingleQueryValue = (value: unknown): string | undefined => {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const provider = getRouterParam(event, 'provider')
|
||||
const query = getQuery(event)
|
||||
// OAuth 回调参数参与身份与 CSRF 校验,拒绝重复参数可避免上下游解析结果不一致。
|
||||
const code = getSingleQueryValue(query.code)
|
||||
const stateStr = getSingleQueryValue(query.state)
|
||||
const callbackLoginType = getSingleQueryValue(query.type)?.trim().toLowerCase()
|
||||
|
||||
if (!provider) {
|
||||
throw createApiError(400, 'AUTH_MISSING_PROVIDER', 'Missing provider')
|
||||
}
|
||||
|
||||
if (!isSupportedOAuthProvider(provider)) {
|
||||
throw createApiError(400, 'AUTH_UNSUPPORTED_OAUTH_PROVIDER', '当前仅支持 GitHub / Casdoor / Google / 聚合登陆 / 第三方 OAuth2')
|
||||
}
|
||||
|
||||
const enabled = await isOAuthProviderEnabled(provider)
|
||||
if (!enabled) {
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=PROVIDER_DISABLED&message=${encodeURIComponent('该 OAuth 登录方式未启用')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw createApiError(400, 'AUTH_MISSING_CODE_OR_STATE', 'OAuth 回调缺少或包含冲突的 code 参数')
|
||||
}
|
||||
if (!stateStr) {
|
||||
throw createApiError(400, 'AUTH_MISSING_CODE_OR_STATE', 'OAuth 回调缺少或包含冲突的 state 参数,无法完成安全验证')
|
||||
}
|
||||
|
||||
// 1. 验证 State
|
||||
const stateCookieNames = getOAuthStateCookieNames(provider === 'aggregate' ? stateStr : undefined)
|
||||
let activeStateCookieNames = stateCookieNames
|
||||
let csrfCookie = getCookie(event, activeStateCookieNames.csrf)
|
||||
let storedFullState = getCookie(event, activeStateCookieNames.fullState)
|
||||
let storedCompactState = getCookie(event, activeStateCookieNames.compactState)
|
||||
|
||||
// 兼容升级前已经发起、仍在十分钟有效期内的登录流程。
|
||||
// 仅在新命名 Cookie 全部缺失时才回退,避免部分丢失时连同有效的新命名 Cookie 一起被丢弃。
|
||||
if (provider === 'aggregate' && !csrfCookie && !storedFullState && !storedCompactState) {
|
||||
activeStateCookieNames = LEGACY_OAUTH_STATE_COOKIE_NAMES
|
||||
csrfCookie = getCookie(event, activeStateCookieNames.csrf)
|
||||
storedFullState = getCookie(event, activeStateCookieNames.fullState)
|
||||
storedCompactState = getCookie(event, activeStateCookieNames.compactState)
|
||||
}
|
||||
|
||||
// 验证失败时同时清理两套命名下的残留 Cookie,避免无效状态干扰后续流程
|
||||
const cleanupStateCookies = () => {
|
||||
const names = new Set([
|
||||
stateCookieNames.csrf,
|
||||
LEGACY_OAUTH_STATE_COOKIE_NAMES.csrf,
|
||||
...(provider === 'aggregate'
|
||||
? [
|
||||
stateCookieNames.fullState,
|
||||
stateCookieNames.compactState,
|
||||
LEGACY_OAUTH_STATE_COOKIE_NAMES.fullState,
|
||||
LEGACY_OAUTH_STATE_COOKIE_NAMES.compactState
|
||||
]
|
||||
: [])
|
||||
])
|
||||
for (const name of names) {
|
||||
deleteCookie(event, name, { path: '/' })
|
||||
}
|
||||
}
|
||||
|
||||
if (!csrfCookie) {
|
||||
cleanupStateCookies()
|
||||
throw createApiError(400, 'AUTH_CSRF_COOKIE_MISSING', 'CSRF验证失败:Cookie丢失,请从登录页面重新开始')
|
||||
}
|
||||
|
||||
// 获取 Origin
|
||||
const origin = getRequestOrigin(event)
|
||||
|
||||
const { stateSecret, redirectUriTemplate } = await getOAuthBaseConfig()
|
||||
const providerConfig = await getProviderRuntimeConfig(provider)
|
||||
let stateToVerify = stateStr
|
||||
|
||||
if (provider === 'aggregate') {
|
||||
// Cookie 比对确认回调属于本流程,HMAC 验签阻止伪造的 state
|
||||
if (
|
||||
!storedFullState ||
|
||||
!storedCompactState ||
|
||||
storedCompactState !== stateStr ||
|
||||
!verifyCompactOAuthState(stateStr, stateSecret)
|
||||
) {
|
||||
cleanupStateCookies()
|
||||
throw createApiError(400, 'AUTH_AGGREGATED_STATE_INVALID', '聚合登录状态无效或已过期')
|
||||
}
|
||||
stateToVerify = decodeOAuthStateCookie(storedFullState)
|
||||
if (!stateToVerify) {
|
||||
cleanupStateCookies()
|
||||
throw createApiError(400, 'AUTH_AGGREGATED_STATE_INVALID', '聚合登录状态无效或已过期')
|
||||
}
|
||||
}
|
||||
|
||||
const state = parseState(stateToVerify, origin, csrfCookie, stateSecret)
|
||||
if (!state) {
|
||||
cleanupStateCookies()
|
||||
throw createApiError(400, 'AUTH_STATE_INVALID', 'Invalid or expired state')
|
||||
}
|
||||
if (state.provider && state.provider !== provider) {
|
||||
throw createApiError(400, 'AUTH_OAUTH_PROVIDER_STATE_MISMATCH', 'OAuth provider 与 state 不匹配')
|
||||
}
|
||||
|
||||
let identityProvider: string = provider
|
||||
if (provider === 'aggregate') {
|
||||
const loginType = state.loginType?.trim().toLowerCase()
|
||||
if (!loginType || !providerConfig.loginTypes?.includes(loginType)) {
|
||||
throw createApiError(400, 'AUTH_AGGREGATED_METHOD_CHANGED', '聚合登录方式未启用或已变更')
|
||||
}
|
||||
if (callbackLoginType !== loginType) {
|
||||
throw createApiError(400, 'AUTH_AGGREGATED_CALLBACK_STATE_MISMATCH', '聚合登录回调类型与 state 不匹配')
|
||||
}
|
||||
providerConfig.loginType = loginType
|
||||
identityProvider = `aggregate:${loginType}`
|
||||
}
|
||||
|
||||
// 清除 CSRF cookie
|
||||
deleteCookie(event, activeStateCookieNames.csrf, { path: '/' })
|
||||
if (provider === 'aggregate') {
|
||||
deleteCookie(event, activeStateCookieNames.fullState, { path: '/' })
|
||||
deleteCookie(event, activeStateCookieNames.compactState, { path: '/' })
|
||||
}
|
||||
|
||||
const strategy = getOAuthStrategy(provider)
|
||||
const redirectUri = getRedirectUri(provider, redirectUriTemplate)
|
||||
|
||||
// 2. 使用 Code 换取 Token
|
||||
let accessToken: string
|
||||
try {
|
||||
accessToken = await strategy.exchangeToken(code, redirectUri, providerConfig)
|
||||
} catch (e: any) {
|
||||
console.error(`[OAuth] ${provider} token exchange failed:`, e.message)
|
||||
const errorMessage =
|
||||
provider === 'aggregate' && typeof e?.message === 'string' && e.message.trim()
|
||||
? `聚合登录授权失败:${e.message.trim()}`
|
||||
: '授权失败,无法获取访问令牌'
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=TOKEN_EXCHANGE_FAILED&message=${encodeURIComponent(errorMessage)}`
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 获取用户信息
|
||||
let userInfo
|
||||
try {
|
||||
userInfo = await strategy.getUserInfo(accessToken, providerConfig)
|
||||
} catch (e: any) {
|
||||
console.error(`[OAuth] ${provider} get user info failed:`, e.message)
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=USER_INFO_FAILED&message=${encodeURIComponent('获取用户信息失败')}`
|
||||
)
|
||||
}
|
||||
|
||||
const providerUserId = userInfo.id
|
||||
const providerUsername = userInfo.username
|
||||
|
||||
return handleUserLoginOrBind(
|
||||
event,
|
||||
identityProvider,
|
||||
providerUserId,
|
||||
providerUsername,
|
||||
state.returnTo,
|
||||
userInfo.avatar
|
||||
)
|
||||
})
|
||||
|
||||
async function handleUserLoginOrBind(
|
||||
event: H3Event,
|
||||
provider: string,
|
||||
providerUserId: string,
|
||||
providerUsername: string,
|
||||
returnTo?: string,
|
||||
avatar?: string
|
||||
) {
|
||||
const isSecure = isSecureRequest(event)
|
||||
const safeReturnTo = getSafeOAuthReturnPath(returnTo)
|
||||
|
||||
// 4. 检查是否已登录(绑定模式)
|
||||
const authToken = getCookie(event, 'auth-token')
|
||||
let currentUser: any = null
|
||||
if (authToken) {
|
||||
try {
|
||||
const payload = JWTEnhanced.verifyToken(authToken)
|
||||
currentUser = payload
|
||||
} catch (e) {
|
||||
// Token 无效或已过期,忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 检查身份关联
|
||||
const existingIdentity = await db.query.userIdentities.findFirst({
|
||||
where: (t, { eq, and }) => and(eq(t.provider, provider), eq(t.providerUserId, providerUserId)),
|
||||
with: { user: true }
|
||||
})
|
||||
|
||||
// 如果用户已登录,则是绑定操作
|
||||
if (currentUser) {
|
||||
let bindingResult:
|
||||
| 'success'
|
||||
| 'already-bound'
|
||||
| 'bound-to-other'
|
||||
| 'invalid-session'
|
||||
| 'inactive'
|
||||
| 'password-change-required'
|
||||
|
||||
try {
|
||||
bindingResult = await db.transaction(async (tx) => {
|
||||
const [currentUserRecord] = await tx
|
||||
.select({
|
||||
id: users.id,
|
||||
status: users.status,
|
||||
tokenVersion: users.tokenVersion,
|
||||
forcePasswordChange: users.forcePasswordChange,
|
||||
passwordChangedAt: users.passwordChangedAt,
|
||||
avatarProvider: users.avatarProvider,
|
||||
avatarProviderUserId: users.avatarProviderUserId
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, currentUser.userId))
|
||||
.for('update')
|
||||
|
||||
if (
|
||||
!currentUserRecord ||
|
||||
// 兼容迁移前签发的无版本号令牌,按版本 0 参与比对,与全局认证中间件语义一致
|
||||
(currentUser.tokenVersion ?? 0) !== currentUserRecord.tokenVersion
|
||||
) {
|
||||
return 'invalid-session'
|
||||
}
|
||||
|
||||
if (currentUserRecord.status !== 'active') {
|
||||
return 'inactive'
|
||||
}
|
||||
|
||||
const [currentSettings] = await tx
|
||||
.select({
|
||||
forcePasswordChangeOnFirstLogin: systemSettings.forcePasswordChangeOnFirstLogin
|
||||
})
|
||||
.from(systemSettings)
|
||||
.limit(1)
|
||||
.for('share')
|
||||
const requirePasswordChange = computeRequirePasswordChange(
|
||||
currentUserRecord,
|
||||
currentSettings?.forcePasswordChangeOnFirstLogin ?? false
|
||||
)
|
||||
|
||||
if (!canBindOAuthIdentity(requirePasswordChange)) {
|
||||
return 'password-change-required'
|
||||
}
|
||||
|
||||
const identity = await tx.query.userIdentities.findFirst({
|
||||
where: (t, { eq, and }) =>
|
||||
and(eq(t.provider, provider), eq(t.providerUserId, providerUserId))
|
||||
})
|
||||
|
||||
if (identity) {
|
||||
if (identity.userId !== currentUser.userId) {
|
||||
return 'bound-to-other'
|
||||
}
|
||||
await syncOAuthIdentityAvatar(tx, currentUserRecord, identity, {
|
||||
provider,
|
||||
providerUserId,
|
||||
providerUsername,
|
||||
avatar
|
||||
})
|
||||
return 'already-bound'
|
||||
}
|
||||
|
||||
await syncOAuthIdentityAvatar(tx, currentUserRecord, null, {
|
||||
provider,
|
||||
providerUserId,
|
||||
providerUsername,
|
||||
avatar
|
||||
})
|
||||
return 'success'
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error?.code !== '23505') {
|
||||
console.error('[OAuth] 绑定第三方账号失败:', error)
|
||||
return sendRedirect(
|
||||
event,
|
||||
'/account?error=' + encodeURIComponent('绑定第三方账号失败,请稍后重试')
|
||||
)
|
||||
}
|
||||
|
||||
const concurrentIdentity = await db.query.userIdentities.findFirst({
|
||||
where: (t, { eq, and }) =>
|
||||
and(eq(t.provider, provider), eq(t.providerUserId, providerUserId))
|
||||
})
|
||||
bindingResult =
|
||||
concurrentIdentity?.userId === currentUser.userId ? 'already-bound' : 'bound-to-other'
|
||||
}
|
||||
|
||||
if (bindingResult === 'success') {
|
||||
return sendRedirect(event, '/account?message=' + encodeURIComponent('绑定成功'))
|
||||
}
|
||||
if (bindingResult === 'already-bound') {
|
||||
return sendRedirect(event, '/account?message=' + encodeURIComponent('账号已绑定'))
|
||||
}
|
||||
if (bindingResult === 'bound-to-other') {
|
||||
return sendRedirect(event, '/account?error=' + encodeURIComponent('该账号已被其他用户绑定'))
|
||||
}
|
||||
if (bindingResult === 'password-change-required') {
|
||||
return sendRedirect(
|
||||
event,
|
||||
'/change-password?error=' + encodeURIComponent('请先完成密码修改后再绑定第三方账号')
|
||||
)
|
||||
}
|
||||
if (bindingResult === 'invalid-session') {
|
||||
deleteCookie(event, 'auth-token')
|
||||
return sendRedirect(event, '/login?error=' + encodeURIComponent('会话已失效,请重新登录'))
|
||||
}
|
||||
|
||||
return sendRedirect(
|
||||
event,
|
||||
'/account?error=' + encodeURIComponent('当前账号状态异常,暂时无法绑定第三方账号')
|
||||
)
|
||||
}
|
||||
|
||||
// 未登录,则是登录或新绑定流程
|
||||
if (existingIdentity && existingIdentity.user) {
|
||||
// 检查用户状态
|
||||
const user = existingIdentity.user
|
||||
if (user.status === 'withdrawn') {
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=ACCOUNT_WITHDRAWN&message=${encodeURIComponent('该账号已退学,限制访问')}`
|
||||
)
|
||||
}
|
||||
if (user.status === 'graduate') {
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=ACCOUNT_GRADUATED&message=${encodeURIComponent('该账号已毕业,限制访问')}`
|
||||
)
|
||||
}
|
||||
if (user.status === 'pending') {
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=ACCOUNT_PENDING_APPROVAL&message=${encodeURIComponent('账号待管理员审核,请耐心等待')}`
|
||||
)
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=ACCOUNT_DISABLED&message=${encodeURIComponent('该账号当前不可用')}`
|
||||
)
|
||||
}
|
||||
if (isUserBlocked(user.id)) {
|
||||
const remaining = getUserBlockRemainingTime(user.id)
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=ACCOUNT_BLOCKED&message=${encodeURIComponent(`账户处于风险控制期,请在 ${remaining} 分钟后重试`)}`
|
||||
)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const [userRecord] = await tx
|
||||
.select({
|
||||
id: users.id,
|
||||
avatarProvider: users.avatarProvider,
|
||||
avatarProviderUserId: users.avatarProviderUserId
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.for('update')
|
||||
|
||||
if (!userRecord) {
|
||||
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
|
||||
}
|
||||
|
||||
await syncOAuthIdentityAvatar(tx, userRecord, existingIdentity, {
|
||||
provider,
|
||||
providerUserId,
|
||||
providerUsername,
|
||||
avatar
|
||||
})
|
||||
|
||||
await tx
|
||||
.update(users)
|
||||
.set({
|
||||
lastLogin: getBeijingTime(),
|
||||
lastLoginIp: getClientIP(event)
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
})
|
||||
|
||||
const { token } = await createAuthSession(event, existingIdentity.user, provider)
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
return sendRedirect(event, safeReturnTo || '/')
|
||||
} else {
|
||||
// 绑定
|
||||
const bindingToken = generateBindingToken({
|
||||
provider: provider,
|
||||
providerUserId,
|
||||
providerUsername,
|
||||
avatar
|
||||
})
|
||||
|
||||
// 将绑定令牌存入 cookie
|
||||
setCookie(event, 'binding-token', bindingToken, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10, // 10分钟
|
||||
path: '/'
|
||||
})
|
||||
|
||||
const redirectQuery = safeReturnTo ? `&redirect=${encodeURIComponent(safeReturnTo)}` : ''
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/login?action=bind&provider=${encodeURIComponent(provider)}&username=${encodeURIComponent(providerUsername)}${redirectQuery}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
encodeOAuthStateCookie,
|
||||
generateCompactOAuthState,
|
||||
generateState,
|
||||
getOAuthStateCookieNames,
|
||||
getRedirectUri,
|
||||
getSafeOAuthReturnPath
|
||||
} from '~~/server/utils/oauth'
|
||||
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
|
||||
import {
|
||||
getOAuthBaseConfig,
|
||||
getProviderRuntimeConfig,
|
||||
isOAuthProviderEnabled,
|
||||
isSupportedOAuthProvider
|
||||
} from '~~/server/services/oauthConfigService'
|
||||
import { getRequestOrigin, getSafeRequestProtocol } from '~~/server/utils/request-utils'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const provider = getRouterParam(event, 'provider')
|
||||
if (!provider) {
|
||||
throw createApiError(400, 'AUTH_MISSING_PROVIDER', 'Missing provider')
|
||||
}
|
||||
|
||||
if (!isSupportedOAuthProvider(provider)) {
|
||||
throw createApiError(
|
||||
400,
|
||||
'AUTH_UNSUPPORTED_OAUTH_PROVIDER',
|
||||
'当前仅支持 GitHub / Casdoor / Google / 聚合登陆 / 第三方 OAuth2'
|
||||
)
|
||||
}
|
||||
|
||||
const enabled = await isOAuthProviderEnabled(provider)
|
||||
if (!enabled) {
|
||||
throw createApiError(403, 'AUTH_OAUTH_PROVIDER_DISABLED', 'OAuth provider is disabled')
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const { stateSecret, redirectUriTemplate } = await getOAuthBaseConfig()
|
||||
const providerConfig = await getProviderRuntimeConfig(provider)
|
||||
let aggregateLoginType: string | undefined
|
||||
|
||||
if (provider === 'aggregate') {
|
||||
aggregateLoginType =
|
||||
typeof query.type === 'string' ? query.type.trim().toLowerCase() : undefined
|
||||
if (!aggregateLoginType || !providerConfig.loginTypes?.includes(aggregateLoginType)) {
|
||||
throw createApiError(400, 'AUTH_AGGREGATED_LOGIN_UNSUPPORTED', '未启用或不支持的聚合登录方式')
|
||||
}
|
||||
providerConfig.loginType = aggregateLoginType
|
||||
}
|
||||
|
||||
const strategy = getOAuthStrategy(provider)
|
||||
|
||||
// 获取 Origin
|
||||
const origin = getRequestOrigin(event)
|
||||
const protocol = getSafeRequestProtocol(event)
|
||||
|
||||
const redirectUri = getRedirectUri(provider, redirectUriTemplate)
|
||||
|
||||
// 代理平台可能重写服务端可见的 Host,且 Auth-Broker 场景下回调域名本就与源站不同,
|
||||
// 因此这里只校验回调地址格式,回调请求仍会通过 state、CSRF 和 host-only Cookie 完成来源验证。
|
||||
try {
|
||||
const redirectUrl = new URL(redirectUri)
|
||||
if (!['http:', 'https:'].includes(redirectUrl.protocol)) {
|
||||
throw new Error('unsupported protocol')
|
||||
}
|
||||
} catch {
|
||||
throw createApiError(
|
||||
400,
|
||||
'AUTH_OAUTH_REDIRECT_INVALID',
|
||||
'OAuth 重定向 URI 配置无效,请在管理员后台检查配置'
|
||||
)
|
||||
}
|
||||
|
||||
const returnTo = getSafeOAuthReturnPath(query.redirect)
|
||||
const { state, csrf } = generateState(origin, provider, stateSecret, returnTo, aggregateLoginType)
|
||||
|
||||
// 在开发环境 (HTTP) 中,必须将 secure 设置为 false,否则浏览器会拒绝设置 cookie
|
||||
const isHttps = protocol === 'https'
|
||||
|
||||
let authorizeState = state
|
||||
if (provider === 'aggregate') {
|
||||
authorizeState = generateCompactOAuthState(origin, stateSecret)
|
||||
|
||||
// 限制并发授权流程数量,防止被放弃的流程在 Cookie 中无限累积导致请求头超限
|
||||
const existingCookies = parseCookies(event)
|
||||
const pendingSuffixes = new Set<string>()
|
||||
for (const name of Object.keys(existingCookies)) {
|
||||
const suffix = name.match(/^oauth_(?:csrf|full_state|compact_state)_(.+)$/)?.[1]
|
||||
if (suffix) pendingSuffixes.add(suffix)
|
||||
}
|
||||
if (pendingSuffixes.size >= 3) {
|
||||
for (const suffix of pendingSuffixes) {
|
||||
deleteCookie(event, `oauth_csrf_${suffix}`, { path: '/' })
|
||||
deleteCookie(event, `oauth_full_state_${suffix}`, { path: '/' })
|
||||
deleteCookie(event, `oauth_compact_state_${suffix}`, { path: '/' })
|
||||
}
|
||||
}
|
||||
}
|
||||
const stateCookieNames = getOAuthStateCookieNames(
|
||||
provider === 'aggregate' ? authorizeState : undefined
|
||||
)
|
||||
const stateCookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: isHttps,
|
||||
sameSite: 'lax' as const,
|
||||
maxAge: 60 * 10,
|
||||
path: '/'
|
||||
}
|
||||
|
||||
// 不指定 domain,让每个授权流程的 Cookie 绑定到当前 host 和独立 state。
|
||||
setCookie(event, stateCookieNames.csrf, csrf, stateCookieOptions)
|
||||
|
||||
if (provider === 'aggregate') {
|
||||
setCookie(event, stateCookieNames.fullState, encodeOAuthStateCookie(state), stateCookieOptions)
|
||||
setCookie(event, stateCookieNames.compactState, authorizeState, stateCookieOptions)
|
||||
}
|
||||
|
||||
let url: string
|
||||
try {
|
||||
url = await strategy.getAuthorizeUrl(redirectUri, authorizeState, providerConfig)
|
||||
} catch (error: any) {
|
||||
if (provider !== 'aggregate') throw error
|
||||
|
||||
deleteCookie(event, stateCookieNames.csrf, { path: '/' })
|
||||
deleteCookie(event, stateCookieNames.fullState, { path: '/' })
|
||||
deleteCookie(event, stateCookieNames.compactState, { path: '/' })
|
||||
const statusCode = error?.statusCode || 500
|
||||
console.error('聚合登录方式暂不可用', {
|
||||
loginType: aggregateLoginType,
|
||||
statusCode,
|
||||
message: error?.message
|
||||
})
|
||||
// 区分服务端配置缺失与上游未开通,避免误导管理员排查方向
|
||||
const isSecurityChallenge =
|
||||
typeof error?.message === 'string' && error.message.includes('安全防护拦截')
|
||||
const errorMessage =
|
||||
statusCode === 500
|
||||
? '聚合登录配置不完整,请联系管理员检查配置。'
|
||||
: isSecurityChallenge
|
||||
? error.message
|
||||
: '当前登录方式暂不可用,可能尚未在聚合登录服务中开通。请尝试其他登录方式或联系管理员。'
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=AGGREGATE_LOGIN_UNAVAILABLE&message=${encodeURIComponent(errorMessage)}`
|
||||
)
|
||||
}
|
||||
|
||||
return sendRedirect(event, url)
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, eq, users, userIdentities, systemSettings } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/auth-session'
|
||||
import { verifyBindingToken } from '~~/server/utils/oauth-token'
|
||||
import {
|
||||
isAccountLocked,
|
||||
isUserBlocked,
|
||||
getUserBlockRemainingTime,
|
||||
recordLoginFailure,
|
||||
recordLoginSuccess
|
||||
} from '~~/server/services/securityService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { and } from 'drizzle-orm'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { isSecureRequest } from '~~/server/utils/request-utils'
|
||||
import {
|
||||
computeRequirePasswordChange,
|
||||
resolveRequirePasswordChange
|
||||
} from '~~/server/utils/system-settings-helper'
|
||||
import { canBindOAuthIdentity } from '~~/server/utils/auth-route-policy'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { syncOAuthIdentityAvatar } from '~~/server/utils/oauth-identity'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<Record<string, unknown> | null>(event)
|
||||
const username = typeof body?.username === 'string' ? body.username : ''
|
||||
const password = typeof body?.password === 'string' ? body.password : ''
|
||||
const bindingToken = getCookie(event, 'binding-token')
|
||||
|
||||
if (!bindingToken) {
|
||||
throw createApiError(
|
||||
400,
|
||||
'AUTH_BINDING_SESSION_EXPIRED',
|
||||
'绑定会话已过期,请重新通过第三方登录发起'
|
||||
)
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = verifyBindingToken(bindingToken)
|
||||
} catch (e) {
|
||||
deleteCookie(event, 'binding-token')
|
||||
throw createApiError(400, 'AUTH_INVALID_BINDING_TOKEN', '无效的绑定令牌')
|
||||
}
|
||||
|
||||
const clientIp = getClientIP(event)
|
||||
|
||||
// 验证用户凭据
|
||||
if (await isAccountLocked(username)) {
|
||||
throw createApiError(423, 'AUTH_ACCOUNT_LOCKED', '账户已被锁定,请稍后重试')
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.username, username)
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
await recordLoginFailure(username, clientIp)
|
||||
throw createApiError(401, 'AUTH_INVALID_CREDENTIALS', '用户名或密码错误')
|
||||
}
|
||||
|
||||
const valid = !!user.password && (await bcrypt.compare(password, user.password))
|
||||
if (!valid) {
|
||||
await recordLoginFailure(username, clientIp)
|
||||
throw createApiError(401, 'AUTH_INVALID_CREDENTIALS', '用户名或密码错误')
|
||||
}
|
||||
|
||||
if (user.status === 'withdrawn') {
|
||||
throw createApiError(403, 'AUTH_ACCOUNT_WITHDRAWN', '该账号已退学,限制访问')
|
||||
}
|
||||
|
||||
if (user.status === 'graduate') {
|
||||
throw createApiError(403, 'AUTH_ACCOUNT_GRADUATED', '该账号已毕业,限制访问')
|
||||
}
|
||||
|
||||
if (user.status === 'pending') {
|
||||
throw createApiError(403, 'AUTH_USER_PENDING_APPROVAL', '账号待管理员审核,请耐心等待')
|
||||
}
|
||||
|
||||
if (user.status !== 'active') {
|
||||
throw createApiError(403, 'AUTH_ACCOUNT_CURRENTLY_UNAVAILABLE', '该账号当前不可用')
|
||||
}
|
||||
|
||||
if (isUserBlocked(user.id)) {
|
||||
const remaining = getUserBlockRemainingTime(user.id)
|
||||
throw createApiError(
|
||||
423,
|
||||
'AUTH_ACCOUNT_RISK_CONTROL',
|
||||
`账户处于风险控制期,请在 ${remaining} 分钟后重试`,
|
||||
{ params: [remaining] }
|
||||
)
|
||||
}
|
||||
|
||||
if (!canBindOAuthIdentity(await resolveRequirePasswordChange(user))) {
|
||||
deleteCookie(event, 'binding-token')
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
throw createApiError(
|
||||
403,
|
||||
'AUTH_PASSWORD_CHANGE_REQUIRED',
|
||||
'请先完成密码修改后再绑定第三方账号',
|
||||
{ requirePasswordChange: true }
|
||||
)
|
||||
}
|
||||
|
||||
const totpIdentity = await db.query.userIdentities.findFirst({
|
||||
where: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, 'totp'))
|
||||
})
|
||||
|
||||
if (totpIdentity) {
|
||||
const tempToken = JWTEnhanced.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
tokenVersion: user.tokenVersion,
|
||||
type: 'pre-auth',
|
||||
scope: '2fa_pending'
|
||||
},
|
||||
{ expiresIn: '5m' }
|
||||
)
|
||||
const methods = ['totp']
|
||||
let maskedEmail = ''
|
||||
|
||||
if (user.email && user.emailVerified) {
|
||||
methods.push('email')
|
||||
const [local, domain] = user.email.split('@')
|
||||
if (local && domain) {
|
||||
maskedEmail = local.length <= 2 ? `***@${domain}` : `${local.slice(0, 2)}****@${domain}`
|
||||
}
|
||||
}
|
||||
|
||||
const isSecure = isSecureRequest(event)
|
||||
|
||||
setCookie(event, 'pre-auth-token', tempToken, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 5,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
requires2FA: true,
|
||||
userId: user.id,
|
||||
methods,
|
||||
maskedEmail
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const [currentUser] = await tx
|
||||
.select({
|
||||
id: users.id,
|
||||
tokenVersion: users.tokenVersion,
|
||||
forcePasswordChange: users.forcePasswordChange,
|
||||
passwordChangedAt: users.passwordChangedAt,
|
||||
avatarProvider: users.avatarProvider,
|
||||
avatarProviderUserId: users.avatarProviderUserId
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.for('update')
|
||||
|
||||
if (!currentUser || currentUser.tokenVersion !== user.tokenVersion) {
|
||||
deleteCookie(event, 'binding-token')
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
throw createApiError(401, 'AUTH_SESSION_EXPIRED', '会话已失效,请重新登录')
|
||||
}
|
||||
|
||||
const [currentSettings] = await tx
|
||||
.select({
|
||||
forcePasswordChangeOnFirstLogin: systemSettings.forcePasswordChangeOnFirstLogin
|
||||
})
|
||||
.from(systemSettings)
|
||||
.limit(1)
|
||||
.for('share')
|
||||
const requirePasswordChange = computeRequirePasswordChange(
|
||||
currentUser,
|
||||
currentSettings?.forcePasswordChangeOnFirstLogin ?? false
|
||||
)
|
||||
|
||||
if (!canBindOAuthIdentity(requirePasswordChange)) {
|
||||
deleteCookie(event, 'binding-token')
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
throw createApiError(
|
||||
403,
|
||||
'AUTH_PASSWORD_CHANGE_REQUIRED',
|
||||
'请先完成密码修改后再绑定第三方账号',
|
||||
{ requirePasswordChange: true }
|
||||
)
|
||||
}
|
||||
|
||||
const existing = await tx.query.userIdentities.findFirst({
|
||||
where: (t, { eq, and }) =>
|
||||
and(eq(t.provider, payload.provider), eq(t.providerUserId, payload.providerUserId))
|
||||
})
|
||||
|
||||
if (existing && existing.userId !== user.id) {
|
||||
throw createApiError(409, 'AUTH_OAUTH_BOUND_OTHER_USER', '该第三方账号已被其他用户绑定')
|
||||
}
|
||||
|
||||
await syncOAuthIdentityAvatar(tx, currentUser, existing, {
|
||||
provider: payload.provider,
|
||||
providerUserId: payload.providerUserId,
|
||||
providerUsername: payload.providerUsername,
|
||||
avatar: payload.avatar
|
||||
})
|
||||
})
|
||||
} catch (e: any) {
|
||||
// 如果用户尝试再次绑定相同的账户,处理唯一约束违规
|
||||
if (e.code === '23505') {
|
||||
// Postgres 唯一性冲突
|
||||
// 这意味着身份已经绑定,这是正常情况。
|
||||
// 我们可以继续执行登录流程。
|
||||
} else {
|
||||
// 绑定失败,清除 cookie 防止重放,或者保留允许重试?
|
||||
// 如果是系统错误,保留 cookie 可能更好。
|
||||
// 但如果是逻辑错误,应该清除。
|
||||
// 这里我们选择抛出错误,前端处理。
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
await recordLoginSuccess(username, clientIp)
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
lastLogin: getBeijingTime(),
|
||||
lastLoginIp: clientIp
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
|
||||
// 登录
|
||||
const { token } = await createAuthSession(event, user, payload.provider || 'oauth')
|
||||
const isSecure = isSecureRequest(event)
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
// 清除绑定令牌
|
||||
deleteCookie(event, 'binding-token')
|
||||
deleteCookie(event, 'pre-auth-token')
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { saveCaptcha } from '~~/server/utils/captcha'
|
||||
import svgCaptcha from 'svg-captcha'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const captcha = svgCaptcha.create({
|
||||
size: 4, // 4 位字符
|
||||
ignoreChars: '0o1il', // 剔除易混淆字符
|
||||
noise: 1, // 干扰线从2减到1,减少视觉杂乱
|
||||
color: true, // 彩色字符
|
||||
background: '#ffffff', // 浅白背景,提高对比度
|
||||
width: 120, // 显式指定宽度,让SVG适配容器
|
||||
height: 40 // 显式指定高度
|
||||
})
|
||||
|
||||
const captchaId = crypto.randomUUID()
|
||||
// 存入统一存储,5 分钟有效
|
||||
await saveCaptcha(captchaId, captcha.text)
|
||||
|
||||
return {
|
||||
id: captchaId,
|
||||
svg: captcha.data // 完整的 <svg>...</svg> 字符串
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { recordLoginFailure, recordLoginSuccess } from '../../services/securityService'
|
||||
import { updateUserPassword } from '../../services/userService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { isSecureRequest } from '~~/server/utils/request-utils'
|
||||
import { getPasswordPolicyViolation } from '~/utils/password-policy'
|
||||
import {
|
||||
PASSWORD_AUDIT_ACTIONS,
|
||||
consumePasswordRateLimit,
|
||||
getPasswordAuditContext,
|
||||
recordPasswordAudit
|
||||
} from '~~/server/services/passwordSecurityService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 验证用户身份
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(401, 'AUTH_UNAUTHORIZED', '未授权')
|
||||
}
|
||||
|
||||
const auditAction = PASSWORD_AUDIT_ACTIONS.CHANGE_PASSWORD
|
||||
const clientIp = getClientIP(event)
|
||||
|
||||
const body = await readBody<Record<string, unknown> | null>(event)
|
||||
const currentPassword = typeof body?.currentPassword === 'string' ? body.currentPassword : ''
|
||||
const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : ''
|
||||
if (!currentPassword || !newPassword) {
|
||||
await recordPasswordAudit(event, user.id, auditAction, false, '缺少当前密码或新密码')
|
||||
throw createApiError(
|
||||
400,
|
||||
'AUTH_CURRENT_AND_NEW_PASSWORD_REQUIRED',
|
||||
'当前密码和新密码都是必需的'
|
||||
)
|
||||
}
|
||||
|
||||
const rateLimit = await consumePasswordRateLimit(user.id, clientIp, auditAction, 10)
|
||||
if (!rateLimit.allowed) {
|
||||
await recordPasswordAudit(event, user.id, auditAction, false, '操作频率超过限制')
|
||||
setResponseHeader(event, 'Retry-After', String(rateLimit.retryAfterSeconds))
|
||||
const retryAfterMinutes = Math.max(1, Math.ceil(rateLimit.retryAfterSeconds / 60))
|
||||
throw createApiError(
|
||||
429,
|
||||
'AUTH_RATE_LIMITED_MINUTES',
|
||||
`密码修改尝试过于频繁,请 ${retryAfterMinutes} 分钟后再试`,
|
||||
{ params: [retryAfterMinutes] }
|
||||
)
|
||||
}
|
||||
|
||||
const policyViolation = getPasswordPolicyViolation(newPassword)
|
||||
if (policyViolation) {
|
||||
await recordPasswordAudit(event, user.id, auditAction, false, policyViolation.message)
|
||||
throw createApiError(400, policyViolation.code, policyViolation.message)
|
||||
}
|
||||
|
||||
let passwordUpdated = false
|
||||
try {
|
||||
// 查询用户详细信息
|
||||
const userDetailsResult = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
password: users.password,
|
||||
tokenVersion: users.tokenVersion
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.limit(1)
|
||||
|
||||
const userDetails = userDetailsResult[0]
|
||||
|
||||
if (!userDetails) {
|
||||
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
|
||||
}
|
||||
|
||||
// 验证当前密码
|
||||
if (!userDetails.password) {
|
||||
throw createApiError(
|
||||
400,
|
||||
'AUTH_PASSWORD_NOT_SET',
|
||||
'当前账号尚未设置密码,请使用初始密码设置功能'
|
||||
)
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(currentPassword, userDetails.password)
|
||||
|
||||
if (!isPasswordValid) {
|
||||
// 记录安全事件
|
||||
await recordLoginFailure(userDetails.username, clientIp)
|
||||
|
||||
throw createApiError(400, 'AUTH_CURRENT_PASSWORD_INCORRECT', '当前密码不正确')
|
||||
}
|
||||
|
||||
// 检查新密码是否与当前密码相同
|
||||
const isSamePassword = await bcrypt.compare(newPassword, userDetails.password)
|
||||
if (isSamePassword) {
|
||||
throw createApiError(400, 'AUTH_NEW_PASSWORD_SAME_AS_CURRENT', '新密码不能与当前密码相同')
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
const { passwordChangedAt, tokenVersion } = await updateUserPassword(user.id, newPassword, {
|
||||
expectedTokenVersion: userDetails.tokenVersion,
|
||||
auditContext: { action: auditAction, ...getPasswordAuditContext(event) }
|
||||
})
|
||||
passwordUpdated = true
|
||||
|
||||
// 密码变更会让旧令牌失效,因此为当前已验证会话立即换发令牌。
|
||||
const newToken = JWTEnhanced.generateToken(user.id, user.role, tokenVersion, event.context.authSessionId)
|
||||
setCookie(event, 'auth-token', newToken, {
|
||||
httpOnly: true,
|
||||
secure: isSecureRequest(event),
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
// 记录成功的密码修改
|
||||
await recordLoginSuccess(userDetails.username, clientIp)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '密码修改成功',
|
||||
passwordChangedAt
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!passwordUpdated) {
|
||||
await recordPasswordAudit(event, user.id, auditAction, false, error.message || '密码修改失败')
|
||||
}
|
||||
|
||||
// 已格式化的错误直接抛出
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 记录错误信息
|
||||
console.error('修改密码过程中发生未处理的错误:', error)
|
||||
|
||||
// 创建错误响应
|
||||
throw createApiError(500, 'AUTH_SYSTEM_ERROR', '修改密码失败,请稍后重试')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
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 { getServerTimestamp } from '~~/server/utils/serverTime'
|
||||
import { SmtpService } from '~~/server/services/smtpService'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { systemSettings } from '~/drizzle/schema'
|
||||
import {
|
||||
generateEmailCode,
|
||||
storeEmailCode,
|
||||
isEmailCodeCooldownActive,
|
||||
isEmailCodeDailyLimitReached,
|
||||
cleanupExpiredEmailCodes
|
||||
} from '~~/server/utils/email-verification'
|
||||
|
||||
const EMAIL_CODE_IP_LIMIT = 10
|
||||
const EMAIL_CODE_IP_WINDOW_MS = 60 * 1000
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : ''
|
||||
const clientIp = getClientIP(event)
|
||||
|
||||
if (!email || !EMAIL_REGEX.test(email)) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '请输入有效的邮箱地址')
|
||||
}
|
||||
|
||||
// 注册/三方注册任一开放时允许发码(否则拒绝,避免成为邮件群发通道)
|
||||
const config = await db.query.systemSettings.findFirst()
|
||||
if (!config?.allowRegister && !config?.allowOAuthRegistration) {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.AUTH_REGISTER_DISABLED, '系统未开放注册')
|
||||
}
|
||||
|
||||
// IP 限流:每分钟最多 10 次
|
||||
const limitResult = await checkDistributedRateLimit(`email-code:${clientIp}`, EMAIL_CODE_IP_LIMIT, EMAIL_CODE_IP_WINDOW_MS)
|
||||
if (!limitResult.isAllowed) {
|
||||
const waitSeconds = Math.ceil((limitResult.resetTime - getServerTimestamp()) / 1000)
|
||||
throw createApiError(429, SERVER_ERROR_CODES.AUTH_RATE_LIMITED_SECONDS, `操作过于频繁,请 ${waitSeconds} 秒后再试`, { params: [waitSeconds] })
|
||||
}
|
||||
|
||||
// 同邮箱 60 秒冷却
|
||||
if (await isEmailCodeCooldownActive(email)) {
|
||||
throw createApiError(429, SERVER_ERROR_CODES.AUTH_EMAIL_CODE_COOLDOWN, '验证码发送过于频繁,请 1 分钟后再试')
|
||||
}
|
||||
|
||||
// 同一邮箱每日发送上限
|
||||
if (await isEmailCodeDailyLimitReached(email)) {
|
||||
throw createApiError(429, SERVER_ERROR_CODES.AUTH_EMAIL_CODE_DAILY_LIMIT, '今日验证码发送次数已达上限,请明天再试')
|
||||
}
|
||||
|
||||
// 邮件服务可用性检查
|
||||
const smtpService = SmtpService.getInstance()
|
||||
if (!(await smtpService.ensureInitialized())) {
|
||||
throw createApiError(503, SERVER_ERROR_CODES.AUTH_EMAIL_SERVICE_UNAVAILABLE, '邮件服务未配置或不可用,请联系管理员')
|
||||
}
|
||||
|
||||
// 先发邮件,发送成功后才落码(失败不落码,也不触发冷却)
|
||||
const code = generateEmailCode()
|
||||
const sent = await smtpService.renderAndSend(email, 'verification.code', {
|
||||
title: 'VoiceHub 注册邮箱验证',
|
||||
// 补 builtin verification 模板所需变量(code 必填)
|
||||
name: '用户',
|
||||
email,
|
||||
code,
|
||||
expiresInMinutes: 5
|
||||
})
|
||||
|
||||
if (!sent) {
|
||||
throw createApiError(503, SERVER_ERROR_CODES.AUTH_EMAIL_SERVICE_UNAVAILABLE, '验证码邮件发送失败,请稍后重试')
|
||||
}
|
||||
|
||||
cleanupExpiredEmailCodes()
|
||||
await storeEmailCode(email, code)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { SmtpService } from '~~/server/services/smtpService'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { formatIPForEmail, getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { checkDistributedRateLimit } from '~~/server/utils/rateLimiter'
|
||||
import { getServerTimestamp } from '~~/server/utils/serverTime'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const clientIP = getClientIP(event)
|
||||
|
||||
// IP 级别限流:每小时最多 5 次请求
|
||||
const rateLimitKey = `forgot_password_ip:${clientIP}`
|
||||
const limitResult = await checkDistributedRateLimit(rateLimitKey, 5, 60 * 60 * 1000)
|
||||
|
||||
if (!limitResult.isAllowed) {
|
||||
const waitMinutes = Math.ceil((limitResult.resetTime - getServerTimestamp()) / 60000)
|
||||
throw createApiError(429, 'AUTH_RATE_LIMITED_MINUTES', `操作过于频繁,请等待 ${waitMinutes} 分钟后再试`, { params: [waitMinutes] })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { username, email } = body
|
||||
|
||||
if (!username) {
|
||||
throw createApiError(400, 'AUTH_ACCOUNT_NAME_REQUIRED', '请提供账号名')
|
||||
}
|
||||
|
||||
try {
|
||||
const userResult = await db.select().from(users).where(eq(users.username, username)).limit(1)
|
||||
const user = userResult[0]
|
||||
|
||||
// 如果未提供邮箱,则进入第一步:检查账号并返回掩码邮箱
|
||||
if (!email) {
|
||||
if (!user) {
|
||||
throw createApiError(404, 'AUTH_ACCOUNT_NAME_NOT_FOUND', '找不到该账号名,请检查是否拼写错误')
|
||||
}
|
||||
if (!user.email) {
|
||||
throw createApiError(400, 'AUTH_NO_SECURITY_EMAIL', '该账号未绑定安全邮箱,无法通过此方式找回密码。请联系管理员。')
|
||||
}
|
||||
|
||||
// 生成掩码邮箱 (例如: a***b@gmail.com)
|
||||
const emailParts = user.email.split('@')
|
||||
const name = emailParts[0]
|
||||
const domain = emailParts[1]
|
||||
let maskedName = name
|
||||
if (name && name.length > 2) {
|
||||
maskedName = name.substring(0, 1) + '***' + name.substring(name.length - 1)
|
||||
} else if (name && name.length === 2) {
|
||||
maskedName = name.substring(0, 1) + '*'
|
||||
}
|
||||
const maskedEmail = `${maskedName}@${domain}`
|
||||
|
||||
return {
|
||||
success: true,
|
||||
step: 2,
|
||||
maskedEmail,
|
||||
message: '请输入完整的邮箱地址以验证身份'
|
||||
}
|
||||
}
|
||||
|
||||
// 无论是否匹配,都返回相同的成功提示
|
||||
if (user && user.email && user.email.toLowerCase() === email.toLowerCase()) {
|
||||
const payload = {
|
||||
type: 'password_reset',
|
||||
userId: user.id,
|
||||
// 仅截取密码哈希的前10位,防止完整哈希泄露被离线破解
|
||||
hash: user.password.substring(0, 10)
|
||||
}
|
||||
const token = JWTEnhanced.sign(payload, { expiresIn: '15m' })
|
||||
|
||||
// 修复 Host Header Injection 风险,优先使用环境变量中配置的主机名
|
||||
const config = useRuntimeConfig()
|
||||
let resetLink = ''
|
||||
|
||||
if (config.public.host) {
|
||||
// 如果配置了主机名,则直接使用
|
||||
const protocol = config.public.host.startsWith('http')
|
||||
? ''
|
||||
: process.env.NODE_ENV === 'production'
|
||||
? 'https://'
|
||||
: 'http://'
|
||||
resetLink = `${protocol}${config.public.host}/reset-password?token=${token}`
|
||||
} else {
|
||||
// 降级回退:使用安全的请求头获取方式
|
||||
const host = getRequestHost(event)
|
||||
const finalProtocol = getRequestProtocol(event)
|
||||
resetLink = `${finalProtocol}://${host}/reset-password?token=${token}`
|
||||
}
|
||||
|
||||
const smtp = SmtpService.getInstance()
|
||||
|
||||
// 确保SMTP配置已初始化
|
||||
await smtp.initializeSmtpConfig()
|
||||
|
||||
const htmlContent = smtp.generateEmailTemplate(
|
||||
'重置密码',
|
||||
`<p>您好,您请求了重置密码。</p><p>请点击下方按钮重置密码(链接在15分钟内有效)。</p><p style="color:#888">如果您没有请求重置密码,请忽略此邮件。</p>`,
|
||||
resetLink,
|
||||
formatIPForEmail(clientIP),
|
||||
true
|
||||
)
|
||||
|
||||
event.waitUntil(
|
||||
smtp
|
||||
.sendMail(user.email, '重置密码 | VoiceHub', htmlContent, undefined, clientIP)
|
||||
.catch((err) => console.error('发送重置密码邮件失败:', err))
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
step: 3,
|
||||
message:
|
||||
'如果账号名和邮箱匹配,重置密码链接已发送至您的邮箱。请查收并按照邮件中的说明重置密码。'
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
console.error('重置密码请求失败:', error)
|
||||
throw createApiError(500, 'AUTH_SYSTEM_ERROR', '系统错误,请稍后重试')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { fetchGradeClassOptions } from '~~/server/utils/grade-class-options'
|
||||
|
||||
// 公开的年级班级选项接口(注册表单使用,无需 binding-token)
|
||||
export default defineEventHandler(async () => {
|
||||
const classes = await fetchGradeClassOptions()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
classes
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { db, eq, userIdentities } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { getIdentityAvatarUrl } from '~~/server/utils/user-avatar'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
|
||||
}
|
||||
|
||||
const userRecord = await db.query.users.findFirst({
|
||||
where: eq(users.id, user.id),
|
||||
columns: {
|
||||
avatarProvider: true,
|
||||
avatarProviderUserId: true
|
||||
}
|
||||
})
|
||||
|
||||
const identities = await db.query.userIdentities.findMany({
|
||||
where: eq(userIdentities.userId, user.id),
|
||||
columns: {
|
||||
id: true, // 需要 id 以便单独删除
|
||||
provider: true,
|
||||
providerUserId: true,
|
||||
providerUsername: true,
|
||||
avatar: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
||||
return identities.map(identity => {
|
||||
if (identity.provider === 'webauthn') {
|
||||
try {
|
||||
const data = JSON.parse(identity.providerUsername)
|
||||
return {
|
||||
...identity,
|
||||
providerUsername: data.label || 'WebAuthn 设备',
|
||||
avatar: null,
|
||||
isAvatarSource: false
|
||||
// 可以在这里把 label 改为 userFriendlyName
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
...identity,
|
||||
providerUsername: '未知设备',
|
||||
avatar: null,
|
||||
isAvatarSource: false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (identity.provider === 'totp') {
|
||||
return {
|
||||
...identity,
|
||||
avatar: null,
|
||||
isAvatarSource: false
|
||||
}
|
||||
}
|
||||
const isAvatarSource =
|
||||
userRecord?.avatarProvider === identity.provider &&
|
||||
userRecord?.avatarProviderUserId === identity.providerUserId
|
||||
return {
|
||||
...identity,
|
||||
avatar: getIdentityAvatarUrl(identity),
|
||||
isAvatarSource
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,316 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, eq, users, userIdentities, and, systemSettings } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import {
|
||||
getAccountLockRemainingTime,
|
||||
getIPBlockRemainingTime,
|
||||
isAccountLocked,
|
||||
isIPBlocked,
|
||||
recordLoginFailure,
|
||||
recordLoginSuccess,
|
||||
recordAccountIpLogin,
|
||||
blockUser,
|
||||
getUserBlockRemainingTime,
|
||||
//导入失败计数查询函数
|
||||
getLoginFailureCount
|
||||
} from '../../services/securityService'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { resolveRequirePasswordChange } from '~~/server/utils/system-settings-helper'
|
||||
import { getPasswordSetupState } from '~~/server/utils/initial-password-policy'
|
||||
|
||||
// 导入验证码校验函数
|
||||
import { verifyAndConsumeCaptcha } from '~~/server/utils/captcha'
|
||||
import type { SystemSettings } from '~/drizzle/schema'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { createAuthSession } from '~~/server/utils/auth-session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
const clientIp = getClientIP(event)
|
||||
|
||||
let captchaId = ''
|
||||
let captchaInput = ''
|
||||
|
||||
if (!body.username || !body.password) {
|
||||
throw createApiError(400, 'AUTH_USERNAME_PASSWORD_REQUIRED', '用户名和密码不能为空')
|
||||
}
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.error('JWT_SECRET environment variable is not set')
|
||||
throw createApiError(500, 'AUTH_SERVER_CONFIG_ERROR', '服务器配置错误')
|
||||
}
|
||||
|
||||
// 数据库连接检查 - 使用简单的查询测试连接
|
||||
try {
|
||||
await db.select().from(users).limit(1)
|
||||
} catch (error) {
|
||||
console.error('Database connection error:', error)
|
||||
throw createApiError(503, 'AUTH_DATABASE_UNAVAILABLE', '数据库服务暂时不可用')
|
||||
}
|
||||
|
||||
// 检查IP是否被限制
|
||||
if (isIPBlocked(clientIp)) {
|
||||
const remainingTime = getIPBlockRemainingTime(clientIp)
|
||||
throw createApiError(423, 'AUTH_IP_BLOCKED_RETRY_MINUTES', `您的IP地址已被限制访问,请在 ${remainingTime} 分钟后重试`, { params: [remainingTime] })
|
||||
}
|
||||
|
||||
// 检查账户是否被锁定
|
||||
if (await isAccountLocked(body.username)) {
|
||||
const remainingTime = await getAccountLockRemainingTime(body.username)
|
||||
throw createApiError(423, 'AUTH_ACCOUNT_LOCKED_MINUTES', `账户已被锁定,请在 ${remainingTime} 分钟后重试`, { params: [remainingTime] })
|
||||
}
|
||||
|
||||
// 读取全局配置:是否启用图形验证码
|
||||
let captchaEnabled = false
|
||||
let captchaProvider = 'graphic'
|
||||
let turnstileSecretKey = ''
|
||||
let captchaMaxFailures = 3
|
||||
try {
|
||||
const settings = await db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] as SystemSettings | undefined)
|
||||
|
||||
if (settings?.captchaEnabled) {
|
||||
captchaEnabled = true
|
||||
captchaProvider = settings.captchaProvider || 'graphic'
|
||||
turnstileSecretKey = settings.turnstileSecretKey || ''
|
||||
if (settings.captchaMaxFailures) {
|
||||
captchaMaxFailures = settings.captchaMaxFailures
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 查询异常(如表不存在)时默认关闭验证码,保证登录可用
|
||||
console.warn('读取验证码配置失败,已暂时禁用:', e)
|
||||
}
|
||||
|
||||
// 图形验证码检查
|
||||
let needCaptcha = false
|
||||
if (captchaEnabled) {
|
||||
if (captchaProvider === 'turnstile') {
|
||||
needCaptcha = true // Turnstile 每次都验证
|
||||
} else {
|
||||
const failCount = await getLoginFailureCount(body.username)
|
||||
needCaptcha = failCount >= captchaMaxFailures
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码校验
|
||||
if (needCaptcha) {
|
||||
if (captchaProvider === 'turnstile') {
|
||||
const turnstileToken = body.turnstileToken
|
||||
|
||||
if (!turnstileSecretKey) {
|
||||
console.error('Turnstile is enabled but secret key is missing!')
|
||||
throw createApiError(500, 'AUTH_CODE_SERVICE_CONFIG_ERROR', '验证码服务配置错误,请联系管理员')
|
||||
}
|
||||
|
||||
if (!turnstileToken) {
|
||||
throw createApiError(400, '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) {
|
||||
console.error('Turnstile verification failed:', result['error-codes'])
|
||||
throw createApiError(400, '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, 'AUTH_CAPTCHA_SERVICE_UNAVAILABLE', '人机验证服务暂时不可用')
|
||||
}
|
||||
} else {
|
||||
captchaId = body.captchaId
|
||||
captchaInput = body.captchaInput
|
||||
if (!captchaId || !captchaInput) {
|
||||
throw createApiError(400, 'AUTH_IMAGE_CAPTCHA_REQUIRED', '请完成图形验证码', { captchaRequired: true, captchaProvider: 'graphic' })
|
||||
}
|
||||
|
||||
const isValid = await verifyAndConsumeCaptcha(captchaId, captchaInput)
|
||||
if (!isValid) {
|
||||
throw createApiError(400, 'AUTH_CODE_INVALID_OR_EXPIRED', '验证码错误或已过期,请重新输入', { captchaRequired: true, captchaProvider: 'graphic' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const userResult = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
grade: users.grade,
|
||||
class: users.class,
|
||||
password: users.password,
|
||||
role: users.role,
|
||||
lastLogin: users.lastLogin,
|
||||
lastLoginIp: users.lastLoginIp,
|
||||
passwordChangedAt: users.passwordChangedAt,
|
||||
forcePasswordChange: users.forcePasswordChange,
|
||||
tokenVersion: users.tokenVersion,
|
||||
status: users.status,
|
||||
email: users.email,
|
||||
emailVerified: users.emailVerified
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.username, body.username))
|
||||
.limit(1)
|
||||
|
||||
const user = userResult[0] || null
|
||||
|
||||
if (!user) {
|
||||
// 记录登录失败(用户不存在)
|
||||
await recordLoginFailure(body.username, clientIp)
|
||||
throw createApiError(401, 'AUTH_INVALID_CREDENTIALS', '用户名或密码错误')
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = await bcrypt.compare(body.password, user.password)
|
||||
if (!isPasswordValid) {
|
||||
// 记录登录失败(密码错误)
|
||||
await recordLoginFailure(body.username, clientIp)
|
||||
throw createApiError(401, 'AUTH_INVALID_CREDENTIALS', '用户名或密码错误')
|
||||
}
|
||||
|
||||
// 检查用户状态 (移到2FA之前,防止已退学用户进行2FA验证)
|
||||
if (user.status === 'withdrawn') {
|
||||
throw createApiError(403, 'AUTH_ACCOUNT_WITHDRAWN', '该账号已退学,限制访问')
|
||||
} else if (user.status === 'graduate') {
|
||||
throw createApiError(403, 'AUTH_ACCOUNT_GRADUATED', '该账号已毕业,限制访问')
|
||||
} else if (user.status === 'banned') {
|
||||
throw createApiError(403, 'AUTH_ACCOUNT_BANNED', '该账号已被封禁')
|
||||
} else if (user.status === 'pending') {
|
||||
throw createApiError(403, 'AUTH_USER_PENDING_APPROVAL', '账号待管理员审核,请耐心等待')
|
||||
} else if (user.status === 'rejected') {
|
||||
throw createApiError(403, 'AUTH_ACCOUNT_CURRENTLY_UNAVAILABLE', '该账号当前不可用')
|
||||
}
|
||||
|
||||
// 普通登录不延续绑定流程,避免残留绑定令牌被后续 2FA 校验意外消费
|
||||
deleteCookie(event, 'binding-token')
|
||||
|
||||
// 检查是否开启2FA
|
||||
const totpIdentity = await db.query.userIdentities.findFirst({
|
||||
where: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, 'totp'))
|
||||
})
|
||||
|
||||
if (totpIdentity) {
|
||||
// 生成预认证临时令牌
|
||||
const tempToken = JWTEnhanced.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
tokenVersion: user.tokenVersion,
|
||||
type: 'pre-auth',
|
||||
scope: '2fa_pending'
|
||||
},
|
||||
{ expiresIn: '5m' }
|
||||
) // 动态构建验证方式列表
|
||||
const methods = ['totp']
|
||||
let maskedEmail = ''
|
||||
if (user.email && user.emailVerified) {
|
||||
methods.push('email')
|
||||
// 生成脱敏邮箱提示
|
||||
const [local, domain] = user.email.split('@')
|
||||
if (local && domain) {
|
||||
maskedEmail = local.length <= 2 ? `***@${domain}` : `${local.slice(0, 2)}****@${domain}`
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
requires2FA: true,
|
||||
userId: user.id,
|
||||
methods,
|
||||
maskedEmail,
|
||||
tempToken
|
||||
}
|
||||
}
|
||||
|
||||
await recordLoginSuccess(body.username, clientIp)
|
||||
|
||||
const ipSwitchExceeded = recordAccountIpLogin(body.username, clientIp)
|
||||
if (ipSwitchExceeded) {
|
||||
blockUser(user.id)
|
||||
const ipRemain = getIPBlockRemainingTime(clientIp)
|
||||
const userRemain = getUserBlockRemainingTime(user.id)
|
||||
throw createApiError(423, 'AUTH_MULTI_IP_PROTECTION', `检测到同一账号短期多IP登录,当前IP限制 ${ipRemain} 分钟,账户保护 ${userRemain} 分钟`, { params: [ipRemain, userRemain] })
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
lastLogin: getBeijingTime(),
|
||||
lastLoginIp: clientIp
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
.catch((err) => console.error('Error updating user login info:', err))
|
||||
|
||||
// 生成JWT
|
||||
const { token } = await createAuthSession(event, user, 'password')
|
||||
|
||||
// 自动判断是否需要secure
|
||||
const isSecure =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
|
||||
|
||||
// 设置cookie
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7天
|
||||
path: '/'
|
||||
})
|
||||
|
||||
const processingTime = Date.now() - startTime
|
||||
console.log(`Login for ${user.username} processed in ${processingTime}ms`)
|
||||
|
||||
const requirePasswordChange = await resolveRequirePasswordChange(user)
|
||||
const passwordSetupState = getPasswordSetupState(user, requirePasswordChange)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
grade: user.grade,
|
||||
class: user.class,
|
||||
role: user.role,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
forcePasswordChange: user.forcePasswordChange,
|
||||
passwordChangedAt: user.passwordChangedAt,
|
||||
requirePasswordChange,
|
||||
// 兼容旧客户端字段名
|
||||
needsPasswordChange: requirePasswordChange,
|
||||
...passwordSetupState
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorTime = Date.now() - startTime
|
||||
console.error(`Login error after ${errorTime}ms:`, error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createApiError(500, 'AUTH_LOGIN_UNKNOWN_ERROR', '登录过程中发生未知错误')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { revokeAuthSession } from '~~/server/utils/auth-session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
console.log('[Auth] User logout requested')
|
||||
|
||||
if (event.context.user?.id && event.context.authSessionId) {
|
||||
await revokeAuthSession(event.context.user.id, event.context.authSessionId, 'logout')
|
||||
}
|
||||
|
||||
// 获取当前用户信息(如果存在)
|
||||
// 清除cookie
|
||||
const isSecure =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
|
||||
setCookie(event, 'auth-token', '', {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '登出成功'
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Logout error:', error)
|
||||
|
||||
// 出错时也要清除cookie
|
||||
try {
|
||||
setCookie(event, 'auth-token', '', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
path: '/'
|
||||
})
|
||||
} catch (cookieError) {
|
||||
console.error('Failed to clear cookie:', cookieError)
|
||||
}
|
||||
|
||||
// 返回成功状态
|
||||
return {
|
||||
success: true,
|
||||
message: '登出成功'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { verifyBindingToken } from '~~/server/utils/oauth-token'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { fetchGradeClassOptions } from '~~/server/utils/grade-class-options'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = await db.query.systemSettings.findFirst()
|
||||
if (!config?.allowOAuthRegistration) {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.AUTH_OAUTH_REGISTER_DISABLED, '系统已关闭第三方账号注册功能')
|
||||
}
|
||||
|
||||
const bindingToken = getCookie(event, 'binding-token')
|
||||
if (!bindingToken) {
|
||||
throw createApiError(401, SERVER_ERROR_CODES.AUTH_REGISTER_SESSION_EXPIRED, '注册会话已过期,请重新通过第三方登录发起')
|
||||
}
|
||||
|
||||
try {
|
||||
verifyBindingToken(bindingToken)
|
||||
} catch {
|
||||
deleteCookie(event, 'binding-token')
|
||||
throw createApiError(401, SERVER_ERROR_CODES.AUTH_INVALID_REGISTER_TOKEN, '无效的注册令牌')
|
||||
}
|
||||
|
||||
const classes = await fetchGradeClassOptions()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
classes
|
||||
}
|
||||
})
|
||||
@@ -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 || '注册失败,请稍后重试')
|
||||
}
|
||||
})
|
||||
@@ -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 || '注册失败,请稍后重试')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { updateUserPassword } from '~~/server/services/userService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { checkDistributedRateLimit } from '~~/server/utils/rateLimiter'
|
||||
import { getServerTimestamp } from '~~/server/utils/serverTime'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { getPasswordPolicyViolation } from '~/utils/password-policy'
|
||||
import {
|
||||
PASSWORD_AUDIT_ACTIONS,
|
||||
getPasswordAuditContext,
|
||||
recordPasswordAudit
|
||||
} from '~~/server/services/passwordSecurityService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const clientIP = getClientIP(event)
|
||||
|
||||
// IP 级别限流:每小时最多 10 次密码重置尝试
|
||||
const rateLimitKey = `reset_password_ip:${clientIP}`
|
||||
const limitResult = await checkDistributedRateLimit(rateLimitKey, 10, 60 * 60 * 1000)
|
||||
|
||||
if (!limitResult.isAllowed) {
|
||||
const waitMinutes = Math.ceil((limitResult.resetTime - getServerTimestamp()) / 60000)
|
||||
throw createApiError(
|
||||
429,
|
||||
'AUTH_RESET_PASSWORD_TOO_MANY',
|
||||
`重置密码尝试次数过多,请等待 ${waitMinutes} 分钟后再试`,
|
||||
{ params: [waitMinutes] }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await readBody<Record<string, unknown> | null>(event)
|
||||
const token = typeof body?.token === 'string' ? body.token : ''
|
||||
const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : ''
|
||||
|
||||
if (!token || !newPassword) {
|
||||
throw createApiError(400, 'AUTH_INCOMPLETE_PARAMS', '参数不完整')
|
||||
}
|
||||
|
||||
const policyViolation = getPasswordPolicyViolation(newPassword)
|
||||
if (policyViolation) {
|
||||
throw createApiError(400, policyViolation.code, policyViolation.message)
|
||||
}
|
||||
|
||||
let auditUserId: number | null = null
|
||||
let passwordUpdated = false
|
||||
try {
|
||||
// 验证并解码token
|
||||
const decoded = JWTEnhanced.verify(token) as any
|
||||
|
||||
if (decoded.type !== 'password_reset' || !decoded.userId || !decoded.hash) {
|
||||
throw createApiError(400, 'AUTH_INVALID_RESET_LINK', '无效的重置链接')
|
||||
}
|
||||
|
||||
// 获取最新用户信息
|
||||
const userResult = await db.select().from(users).where(eq(users.id, decoded.userId)).limit(1)
|
||||
const user = userResult[0]
|
||||
|
||||
if (!user) {
|
||||
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
|
||||
}
|
||||
auditUserId = user.id
|
||||
|
||||
// 验证 hash 是否匹配当前密码的前10位
|
||||
// 如果用户已经修改过密码,则 user.password 发生变化,旧 token 失效
|
||||
// 使用恒定时间比较,避免通过时序侧信道泄露密码是否已被修改
|
||||
const expectedHash = Buffer.from(user.password.substring(0, 10), 'utf8')
|
||||
const providedHash = Buffer.from(typeof decoded.hash === 'string' ? decoded.hash : '', 'utf8')
|
||||
if (
|
||||
expectedHash.length !== providedHash.length ||
|
||||
!crypto.timingSafeEqual(expectedHash, providedHash)
|
||||
) {
|
||||
throw createApiError(400, 'AUTH_RESET_LINK_INVALIDATED', '该重置链接已失效(密码已被修改)')
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
await updateUserPassword(user.id, newPassword, {
|
||||
expectedTokenVersion: user.tokenVersion,
|
||||
auditContext: {
|
||||
action: PASSWORD_AUDIT_ACTIONS.RESET_PASSWORD,
|
||||
...getPasswordAuditContext(event)
|
||||
}
|
||||
})
|
||||
passwordUpdated = true
|
||||
|
||||
return { success: true, message: '密码重置成功,请使用新密码登录' }
|
||||
} catch (error: any) {
|
||||
if (auditUserId && !passwordUpdated) {
|
||||
await recordPasswordAudit(
|
||||
event,
|
||||
auditUserId,
|
||||
PASSWORD_AUDIT_ACTIONS.RESET_PASSWORD,
|
||||
false,
|
||||
error.message || '重置密码失败'
|
||||
)
|
||||
}
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
throw createApiError(400, 'AUTH_RESET_LINK_EXPIRED', '重置链接已过期,请重新申请')
|
||||
}
|
||||
if (error.statusCode) throw error
|
||||
throw createApiError(400, 'AUTH_INVALID_RESET_LINK', '无效的重置链接')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { updateUserPassword } from '~~/server/services/userService'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { isSecureRequest } from '~~/server/utils/request-utils'
|
||||
import { getInitialPasswordPolicyViolation } from '~/utils/password-policy'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { canSetInitialPassword } from '~~/server/utils/initial-password-policy'
|
||||
import {
|
||||
PASSWORD_AUDIT_ACTIONS,
|
||||
consumePasswordRateLimit,
|
||||
getPasswordAuditContext,
|
||||
recordPasswordAudit
|
||||
} from '~~/server/services/passwordSecurityService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(401, 'AUTH_UNAUTHORIZED', '未授权')
|
||||
}
|
||||
|
||||
const auditAction = PASSWORD_AUDIT_ACTIONS.INITIAL_PASSWORD
|
||||
const body = await readBody<Record<string, unknown> | null>(event)
|
||||
const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : ''
|
||||
if (!newPassword) {
|
||||
await recordPasswordAudit(event, user.id, auditAction, false, '新密码为空')
|
||||
throw createApiError(400, 'AUTH_NEW_PASSWORD_REQUIRED', '新密码不能为空')
|
||||
}
|
||||
|
||||
const clientIp = getClientIP(event)
|
||||
const rateLimit = await consumePasswordRateLimit(user.id, clientIp, auditAction, 5)
|
||||
if (!rateLimit.allowed) {
|
||||
await recordPasswordAudit(event, user.id, auditAction, false, '操作频率超过限制')
|
||||
setResponseHeader(event, 'Retry-After', String(rateLimit.retryAfterSeconds))
|
||||
const retryAfterMinutes = Math.max(1, Math.ceil(rateLimit.retryAfterSeconds / 60))
|
||||
throw createApiError(
|
||||
429,
|
||||
'AUTH_RATE_LIMITED_MINUTES',
|
||||
`初始密码设置尝试过于频繁,请 ${retryAfterMinutes} 分钟后再试`,
|
||||
{ params: [retryAfterMinutes] }
|
||||
)
|
||||
}
|
||||
|
||||
const policyViolation = getInitialPasswordPolicyViolation(newPassword)
|
||||
if (policyViolation) {
|
||||
await recordPasswordAudit(event, user.id, auditAction, false, policyViolation.message)
|
||||
throw createApiError(400, policyViolation.code, policyViolation.message)
|
||||
}
|
||||
|
||||
let passwordUpdated = false
|
||||
try {
|
||||
const currentUserResult = await db
|
||||
.select({
|
||||
password: users.password,
|
||||
passwordChangedAt: users.passwordChangedAt,
|
||||
tokenVersion: users.tokenVersion
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.limit(1)
|
||||
const currentUser = currentUserResult[0]
|
||||
if (!currentUser) {
|
||||
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
|
||||
}
|
||||
|
||||
// 已完成初始设置的账号必须通过需要旧密码的修改流程。
|
||||
if (currentUser.passwordChangedAt) {
|
||||
throw createApiError(400, 'AUTH_PASSWORD_ALREADY_SET', '您已经设置过密码,请使用修改密码功能')
|
||||
}
|
||||
|
||||
if (!canSetInitialPassword(user, currentUser)) {
|
||||
throw createApiError(
|
||||
403,
|
||||
'AUTH_INITIAL_PASSWORD_NOT_REQUIRED',
|
||||
'当前账号不需要设置初始密码,请使用修改密码功能'
|
||||
)
|
||||
}
|
||||
|
||||
// OAuth 账号可能没有可用于比对的旧密码,不能把 null 传给 bcrypt。
|
||||
if (currentUser.password && (await bcrypt.compare(newPassword, currentUser.password))) {
|
||||
throw createApiError(400, 'AUTH_NEW_PASSWORD_SAME_AS_CURRENT', '新密码不能与当前密码相同')
|
||||
}
|
||||
|
||||
const { passwordChangedAt, tokenVersion } = await updateUserPassword(user.id, newPassword, {
|
||||
expectedTokenVersion: currentUser.tokenVersion,
|
||||
auditContext: { action: auditAction, ...getPasswordAuditContext(event) }
|
||||
})
|
||||
passwordUpdated = true
|
||||
|
||||
const newToken = JWTEnhanced.generateToken(user.id, user.role, tokenVersion, event.context.authSessionId)
|
||||
setCookie(event, 'auth-token', newToken, {
|
||||
httpOnly: true,
|
||||
secure: isSecureRequest(event),
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '初始密码设置成功',
|
||||
passwordChangedAt
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!passwordUpdated) {
|
||||
await recordPasswordAudit(
|
||||
event,
|
||||
user.id,
|
||||
auditAction,
|
||||
false,
|
||||
error.message || '初始密码设置失败'
|
||||
)
|
||||
}
|
||||
|
||||
if (error.statusCode) throw error
|
||||
throw createApiError(500, 'AUTH_SYSTEM_ERROR', '初始密码设置失败,请稍后重试')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { db, eq, and, users, userIdentities } from '~/drizzle/db'
|
||||
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { resolveAvatarSource } from '~~/server/utils/user-avatar'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { provider, id } = body
|
||||
|
||||
if (!provider) {
|
||||
throw createApiError(400, 'AUTH_MISSING_PROVIDER_PARAM', '缺少提供商参数')
|
||||
}
|
||||
|
||||
const whereCondition = id
|
||||
? and(
|
||||
eq(userIdentities.id, id),
|
||||
eq(userIdentities.userId, user.id),
|
||||
eq(userIdentities.provider, provider)
|
||||
)
|
||||
: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, provider))
|
||||
|
||||
const rpID = provider === 'webauthn' ? getWebAuthnConfig(event).rpID : undefined
|
||||
const removedIdentities = await db.transaction(async (tx) => {
|
||||
const targetIdentities = await tx.query.userIdentities.findMany({
|
||||
where: whereCondition,
|
||||
columns: {
|
||||
id: true,
|
||||
provider: true,
|
||||
providerUserId: true,
|
||||
providerUsername: true,
|
||||
avatar: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
||||
const userRecord = await tx.query.users.findFirst({
|
||||
where: eq(users.id, user.id),
|
||||
columns: {
|
||||
avatarProvider: true,
|
||||
avatarProviderUserId: true
|
||||
}
|
||||
})
|
||||
const targetKeys = new Set(
|
||||
targetIdentities.map((identity) => `${identity.provider}:${identity.providerUserId}`)
|
||||
)
|
||||
const isDeletingAvatarSource =
|
||||
!!userRecord &&
|
||||
targetIdentities.some(
|
||||
(identity) =>
|
||||
identity.provider === userRecord.avatarProvider &&
|
||||
identity.providerUserId === userRecord.avatarProviderUserId
|
||||
)
|
||||
|
||||
if (isDeletingAvatarSource) {
|
||||
const allIdentities = await tx.query.userIdentities.findMany({
|
||||
where: eq(userIdentities.userId, user.id),
|
||||
columns: {
|
||||
id: true,
|
||||
provider: true,
|
||||
providerUserId: true,
|
||||
providerUsername: true,
|
||||
avatar: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
const remainingIdentities = allIdentities.filter(
|
||||
(identity) => !targetKeys.has(`${identity.provider}:${identity.providerUserId}`)
|
||||
)
|
||||
const fallback = resolveAvatarSource(userRecord, remainingIdentities)
|
||||
await tx
|
||||
.update(users)
|
||||
.set({
|
||||
avatarProvider: fallback?.provider ?? null,
|
||||
avatarProviderUserId: fallback?.providerUserId ?? null
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
}
|
||||
|
||||
return tx
|
||||
.delete(userIdentities)
|
||||
.where(whereCondition)
|
||||
.returning({ credentialId: userIdentities.providerUserId })
|
||||
})
|
||||
|
||||
const passkeyCleanup = rpID
|
||||
? removedIdentities.map((identity) => ({
|
||||
credentialId: identity.credentialId,
|
||||
rpId: rpID
|
||||
}))
|
||||
: []
|
||||
|
||||
return { success: true, passkeyCleanup }
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { resolveRequirePasswordChange } from '~~/server/utils/system-settings-helper'
|
||||
import { getPasswordSetupState } from '~~/server/utils/initial-password-policy'
|
||||
import { resolveAvatarSource } from '~~/server/utils/user-avatar'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const authUser = event.context.user
|
||||
if (!authUser) {
|
||||
throw createApiError(401, 'AUTH_TOKEN_MISSING', '未提供认证令牌')
|
||||
}
|
||||
|
||||
const userId = authUser.id
|
||||
|
||||
// 用户资料始终从数据库获取,避免权限和绑定状态缓存过期。
|
||||
const userResult = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
class: true,
|
||||
role: true,
|
||||
password: true,
|
||||
forcePasswordChange: true,
|
||||
passwordChangedAt: true,
|
||||
avatarProvider: true,
|
||||
avatarProviderUserId: true
|
||||
},
|
||||
with: {
|
||||
identities: {
|
||||
columns: {
|
||||
provider: true,
|
||||
providerUsername: true,
|
||||
providerUserId: true,
|
||||
avatar: true,
|
||||
createdAt: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const dbUser = userResult || null
|
||||
|
||||
if (!dbUser) {
|
||||
throw createApiError(401, 'USER_NOT_FOUND', '用户不存在')
|
||||
}
|
||||
|
||||
// 与登录接口保持一致:按系统开关与用户状态计算强制改密及首次设置密码状态
|
||||
const requirePasswordChange = await resolveRequirePasswordChange(dbUser)
|
||||
const passwordSetupState = getPasswordSetupState(dbUser, requirePasswordChange)
|
||||
|
||||
// 构建返回的用户对象,只包含需要的字段
|
||||
const avatarSource = resolveAvatarSource(dbUser, dbUser.identities || [])
|
||||
const user = {
|
||||
id: dbUser.id,
|
||||
username: dbUser.username,
|
||||
name: dbUser.name,
|
||||
grade: dbUser.grade,
|
||||
class: dbUser.class,
|
||||
role: dbUser.role,
|
||||
forcePasswordChange: dbUser.forcePasswordChange,
|
||||
passwordChangedAt: dbUser.passwordChangedAt,
|
||||
requirePasswordChange,
|
||||
...passwordSetupState,
|
||||
has2FA: dbUser.identities?.some((id: any) => id.provider === 'totp') || false,
|
||||
avatar: avatarSource?.url ?? null
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
valid: true
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { generateAuthenticationOptions } from '@simplewebauthn/server'
|
||||
import { setWebAuthnChallenge } from '~~/server/utils/webauthn-token'
|
||||
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { rpID } = getWebAuthnConfig(event)
|
||||
if (!rpID) {
|
||||
throw createApiError(500, 'AUTH_WEBAUTHN_RP_ID_INVALID', 'WebAuthn RP ID 配置无效')
|
||||
}
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
userVerification: 'preferred',
|
||||
allowCredentials: []
|
||||
})
|
||||
|
||||
setWebAuthnChallenge(event, options.challenge, 'authentication')
|
||||
|
||||
return options
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
|
||||
import { getWebAuthnChallenge, clearWebAuthnChallenge } from '~~/server/utils/webauthn-token'
|
||||
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
|
||||
import { db, eq, and, userIdentities } from '~/drizzle/db'
|
||||
import { defineEventHandler, setCookie } from 'h3'
|
||||
import { createAuthSession } from '~~/server/utils/auth-session'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const challengeData = getWebAuthnChallenge(event)
|
||||
|
||||
if (!challengeData) {
|
||||
throw createApiError(400, 'AUTH_CHALLENGE_EXPIRED', 'Challenge 已失效')
|
||||
}
|
||||
|
||||
// 通过 credentialID 查找对应的用户身份
|
||||
const credentialID = body.id
|
||||
if (!credentialID) {
|
||||
throw createApiError(400, 'AUTH_MISSING_CREDENTIAL_ID', '缺少 Credential ID')
|
||||
}
|
||||
|
||||
// Challenge 只能使用一次,避免失败响应被重复提交。
|
||||
clearWebAuthnChallenge(event)
|
||||
|
||||
const identity = await db.query.userIdentities.findFirst({
|
||||
where: and(
|
||||
eq(userIdentities.provider, 'webauthn'),
|
||||
eq(userIdentities.providerUserId, credentialID)
|
||||
),
|
||||
with: { user: true }
|
||||
})
|
||||
|
||||
if (!identity) {
|
||||
throw createApiError(400, 'AUTH_PASSKEY_ACCOUNT_NOT_FOUND', '未找到该 Passkey 关联的账号')
|
||||
}
|
||||
|
||||
const user = identity.user
|
||||
|
||||
// 检查用户账号状态
|
||||
if (!user || user.status !== 'active') {
|
||||
throw createApiError(403, 'AUTH_ACCOUNT_UNAVAILABLE_OR_RESTRICTED', '该账号不可用或已限制访问')
|
||||
}
|
||||
|
||||
// 解析存储的凭证数据
|
||||
if (!identity.providerUsername) {
|
||||
throw createApiError(500, 'AUTH_CREDENTIAL_MISSING', '凭证数据缺失')
|
||||
}
|
||||
|
||||
let credentialData
|
||||
try {
|
||||
credentialData = JSON.parse(identity.providerUsername)
|
||||
} catch {
|
||||
throw createApiError(500, 'AUTH_CREDENTIAL_CORRUPTED', '凭证数据损坏')
|
||||
}
|
||||
|
||||
const { publicKey, counter } = credentialData
|
||||
|
||||
// 验证公钥存在
|
||||
if (!publicKey) {
|
||||
throw createApiError(500, 'AUTH_CREDENTIAL_MISSING_PUBLICKEY', '凭证数据缺失 publicKey')
|
||||
}
|
||||
|
||||
const { rpID, origin } = getWebAuthnConfig(event)
|
||||
if (!rpID) {
|
||||
throw createApiError(500, 'AUTH_WEBAUTHN_RP_ID_INVALID', 'WebAuthn RP ID 配置无效')
|
||||
}
|
||||
|
||||
// 构造 WebAuthn 凭证对象
|
||||
const credential = {
|
||||
id: credentialID,
|
||||
publicKey: Buffer.from(publicKey, 'base64url'),
|
||||
counter: Number(counter || 0),
|
||||
transports: credentialData.transports
|
||||
}
|
||||
|
||||
try {
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: body,
|
||||
expectedChallenge: challengeData.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
credential,
|
||||
requireUserVerification: true
|
||||
})
|
||||
|
||||
if (verification.verified) {
|
||||
const { newCounter, credentialDeviceType, credentialBackedUp } =
|
||||
verification.authenticationInfo
|
||||
credentialData.counter = newCounter
|
||||
credentialData.credentialDeviceType = credentialDeviceType
|
||||
credentialData.credentialBackedUp = credentialBackedUp
|
||||
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ providerUsername: JSON.stringify(credentialData) })
|
||||
.where(eq(userIdentities.id, identity.id))
|
||||
|
||||
// 签发登录 Token
|
||||
const { token } = await createAuthSession(event, identity.user, 'webauthn')
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
return { success: true, redirect: '/' }
|
||||
} else {
|
||||
throw createApiError(400, 'AUTH_VERIFICATION_FAILED', '验证失败')
|
||||
}
|
||||
} catch (error) {
|
||||
// 透传已携带错误码的业务错误,避免被重新包装后丢失 code 通道。
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error
|
||||
console.error('WebAuthn 登录验证失败:', error)
|
||||
const message = error instanceof Error ? error.message : '验证失败'
|
||||
throw createApiError(400, 'AUTH_VERIFICATION_FAILED', message)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { generateRegistrationOptions } from '@simplewebauthn/server'
|
||||
import { isoUint8Array } from '@simplewebauthn/server/helpers'
|
||||
import { setWebAuthnChallenge } from '~~/server/utils/webauthn-token'
|
||||
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
|
||||
import { db, eq, and, userIdentities } from '~/drizzle/db'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
|
||||
}
|
||||
|
||||
// 获取该用户已绑定的 WebAuthn 凭证
|
||||
const existingCredentials = await db.query.userIdentities.findMany({
|
||||
where: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, 'webauthn')),
|
||||
columns: {
|
||||
providerUserId: true,
|
||||
providerUsername: true
|
||||
}
|
||||
})
|
||||
|
||||
// 防止重复注册相同凭证,使用存储的实际 transports 信息
|
||||
const excludeCredentials = existingCredentials.map((cred) => {
|
||||
let transports: string[] | undefined
|
||||
try {
|
||||
if (cred.providerUsername) {
|
||||
const data = JSON.parse(cred.providerUsername)
|
||||
transports = data.transports
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 WebAuthn 凭证数据失败:', e)
|
||||
}
|
||||
return {
|
||||
id: cred.providerUserId,
|
||||
type: 'public-key' as const,
|
||||
transports
|
||||
}
|
||||
})
|
||||
|
||||
const { rpID } = getWebAuthnConfig(event)
|
||||
const rpName = 'VoiceHub'
|
||||
const userName = user.username || user.email || user.id.toString()
|
||||
const userDisplayName = user.name || userName
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userID: isoUint8Array.fromUTF8String(user.id.toString()),
|
||||
userName,
|
||||
userDisplayName,
|
||||
attestationType: 'none',
|
||||
supportedAlgorithmIDs: [-7, -257],
|
||||
excludeCredentials,
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred'
|
||||
}
|
||||
})
|
||||
|
||||
// 存储注册挑战到 Cookie
|
||||
setWebAuthnChallenge(event, options.challenge, user.id.toString())
|
||||
|
||||
return options
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { verifyRegistrationResponse } from '@simplewebauthn/server'
|
||||
import { getWebAuthnChallenge, clearWebAuthnChallenge } from '~~/server/utils/webauthn-token'
|
||||
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
|
||||
import { db, userIdentities } from '~/drizzle/db'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
const VALID_TRANSPORTS = ['ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', 'usb']
|
||||
|
||||
function sanitizeTransports(transports: unknown): string[] {
|
||||
if (!Array.isArray(transports)) return []
|
||||
return transports.filter(
|
||||
(t): t is string => typeof t === 'string' && VALID_TRANSPORTS.includes(t)
|
||||
)
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const challengeData = getWebAuthnChallenge(event)
|
||||
|
||||
if (!challengeData || challengeData.userId !== user.id.toString()) {
|
||||
throw createApiError(400, 'AUTH_CHALLENGE_INVALID', 'Challenge 已失效或不匹配')
|
||||
}
|
||||
|
||||
// Challenge 只能使用一次,验证失败后也必须重新生成。
|
||||
clearWebAuthnChallenge(event)
|
||||
|
||||
const { rpID, origin } = getWebAuthnConfig(event)
|
||||
|
||||
try {
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: body,
|
||||
expectedChallenge: challengeData.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: true
|
||||
})
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo
|
||||
|
||||
// 提取凭证数据
|
||||
const { id: credentialID, publicKey: credentialPublicKey, counter, transports } = credential
|
||||
|
||||
if (!credentialID || !credentialPublicKey) {
|
||||
throw new Error('注册信息缺少凭证 ID 或公钥')
|
||||
}
|
||||
|
||||
// 转换为 Base64URL 格式存储
|
||||
const credentialIDBase64 = credentialID
|
||||
const publicKeyBase64 = Buffer.from(credentialPublicKey).toString('base64url')
|
||||
|
||||
if (!publicKeyBase64) {
|
||||
throw new Error('生成的公钥为空,注册失败')
|
||||
}
|
||||
|
||||
// 构造凭证数据
|
||||
const credentialData = {
|
||||
label: body.label || 'WebAuthn 设备',
|
||||
publicKey: publicKeyBase64,
|
||||
counter: Number(counter),
|
||||
transports: sanitizeTransports(transports),
|
||||
credentialDeviceType,
|
||||
credentialBackedUp
|
||||
}
|
||||
|
||||
// 存储到数据库
|
||||
await db.insert(userIdentities).values({
|
||||
userId: user.id,
|
||||
provider: 'webauthn',
|
||||
providerUserId: credentialIDBase64,
|
||||
providerUsername: JSON.stringify(credentialData),
|
||||
createdAt: new Date()
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} else {
|
||||
throw createApiError(400, 'AUTH_VERIFICATION_FAILED', '验证失败')
|
||||
}
|
||||
} catch (error) {
|
||||
// 透传已携带错误码的业务错误,避免被重新包装后丢失 code 通道。
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error
|
||||
console.error('WebAuthn 验证失败:', error)
|
||||
const message = error instanceof Error ? error.message : '验证失败'
|
||||
throw createApiError(400, 'AUTH_VERIFICATION_FAILED', message)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { db, eq, and, userIdentities, sql } from '~/drizzle/db'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
|
||||
}
|
||||
|
||||
interface RenameRequestBody {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
const body = await readBody<RenameRequestBody>(event)
|
||||
const { id, name: rawName } = body
|
||||
const name = typeof rawName === 'string' ? rawName.trim() : ''
|
||||
|
||||
if (!id || !name) {
|
||||
throw createApiError(400, 'COMMON_INVALID_PARAMS', '参数错误')
|
||||
}
|
||||
|
||||
if (name.length > 50) {
|
||||
throw createApiError(400, 'AUTH_DEVICE_NAME_TOO_LONG', '设备名称过长')
|
||||
}
|
||||
|
||||
// 查找对应的 Passkey
|
||||
const identity = await db.query.userIdentities.findFirst({
|
||||
where: and(
|
||||
eq(userIdentities.id, id),
|
||||
eq(userIdentities.userId, user.id),
|
||||
eq(userIdentities.provider, 'webauthn')
|
||||
)
|
||||
})
|
||||
|
||||
if (!identity) {
|
||||
throw createApiError(404, 'AUTH_DEVICE_NOT_FOUND', '未找到该设备')
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 jsonb_set 原子更新 label,避免覆盖 concurrent login 产生的 counter 更新
|
||||
await db.update(userIdentities)
|
||||
.set({
|
||||
providerUsername: sql`jsonb_set(${userIdentities.providerUsername}::jsonb, '{label}', ${JSON.stringify(name)}::jsonb)::text`
|
||||
})
|
||||
.where(eq(userIdentities.id, id))
|
||||
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
console.error('更新设备名称失败:', e)
|
||||
throw createApiError(500, 'AUTH_UPDATE_FAILED', '更新失败')
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user