Files
BYW-VoiceHub/server/api/auth/2fa/verify.post.ts
T

293 lines
10 KiB
TypeScript

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
}
}
})