293 lines
10 KiB
TypeScript
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
|
|
}
|
|
}
|
|
})
|