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