253 lines
7.8 KiB
TypeScript
253 lines
7.8 KiB
TypeScript
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 }
|
|
})
|