feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user