初次提交
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { db, users, eq } from '~/drizzle/db'
|
||||
import { twoFactorCodes } from '~~/server/utils/twoFactorStore'
|
||||
import { SmtpService } from '~~/server/services/smtpService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { randomInt } from 'crypto'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { userId: reqUserId, token, email } = await readBody(event)
|
||||
|
||||
// 必须提供预认证令牌
|
||||
if (!token) {
|
||||
throw createError({ statusCode: 400, message: '缺少预认证令牌' })
|
||||
}
|
||||
|
||||
let userId: number
|
||||
try {
|
||||
const decoded = JWTEnhanced.verify(token) as any
|
||||
if (decoded.type !== 'pre-auth' || decoded.scope !== '2fa_pending') {
|
||||
throw new Error('无效的预认证令牌')
|
||||
}
|
||||
userId = decoded.userId
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 401, message: '会话已失效,请重新登录' })
|
||||
}
|
||||
|
||||
// 校验 userId 是否匹配
|
||||
if (!reqUserId || Number(reqUserId) !== userId) {
|
||||
throw createError({ statusCode: 403, message: '非法操作:用户ID不匹配' })
|
||||
}
|
||||
|
||||
// 获取用户邮箱
|
||||
const userResult = await db.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
emailVerified: users.emailVerified,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
|
||||
const user = userResult[0]
|
||||
// 增加 emailVerified 校验
|
||||
if (!user || !user.email || !user.emailVerified) {
|
||||
throw createError({ statusCode: 400, message: '用户不存在或未绑定邮箱' })
|
||||
}
|
||||
|
||||
// 校验用户输入的邮箱是否匹配
|
||||
if (!email || email.trim().toLowerCase() !== user.email.toLowerCase()) {
|
||||
throw createError({ statusCode: 400, message: '邮箱地址不匹配' })
|
||||
}
|
||||
|
||||
// 检查是否在冷却时间内
|
||||
const existingCode = twoFactorCodes.get(userId)
|
||||
if (existingCode && existingCode.expiresAt > Date.now()) {
|
||||
// 5 * 60 * 1000 = 300000ms
|
||||
const totalDuration = 5 * 60 * 1000
|
||||
const timePassed = totalDuration - (existingCode.expiresAt - Date.now())
|
||||
|
||||
if (timePassed < 60 * 1000) { // 60秒冷却
|
||||
const remainingSeconds = Math.ceil((60000 - timePassed) / 1000)
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: `操作过于频繁,请等待 ${remainingSeconds} 秒后再试`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const code = randomInt(100000, 999999).toString()
|
||||
twoFactorCodes.set(userId, {
|
||||
code,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||
attempts: 0
|
||||
})
|
||||
|
||||
const clientIP = getClientIP(event)
|
||||
|
||||
// 发送邮件
|
||||
const smtp = SmtpService.getInstance()
|
||||
await smtp.initializeSmtpConfig()
|
||||
|
||||
const sent = await smtp.renderAndSend(
|
||||
user.email,
|
||||
'verification.code',
|
||||
{
|
||||
name: user.name || '用户',
|
||||
email: user.email,
|
||||
code,
|
||||
expiresInMinutes: 5,
|
||||
action: '登录验证'
|
||||
},
|
||||
clientIP
|
||||
)
|
||||
|
||||
if (!sent) {
|
||||
throw createError({ statusCode: 500, message: '验证码发送失败' })
|
||||
}
|
||||
|
||||
return { success: true, message: '验证码已发送至您的邮箱' }
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import otplib from 'otplib'
|
||||
const { authenticator } = otplib
|
||||
import { db, userIdentities, eq, and, users } from '~/drizzle/db'
|
||||
import { twoFactorCodes } from '~~/server/utils/twoFactorStore'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { code, type, token } = body
|
||||
|
||||
if (!code || !type) {
|
||||
throw createError({ statusCode: 400, message: '缺少必要参数' })
|
||||
}
|
||||
|
||||
// 验证预认证临时令牌
|
||||
let targetUserId: number
|
||||
|
||||
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
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 401, message: '会话已失效,请重新登录' })
|
||||
}
|
||||
} else {
|
||||
// 强制要求 Token
|
||||
throw createError({ statusCode: 400, message: '缺少预认证令牌,请重新登录' })
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const userResult = await db.select().from(users).where(eq(users.id, targetUserId)).limit(1)
|
||||
const user = userResult[0]
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, message: '用户不存在' })
|
||||
}
|
||||
|
||||
if (user.status !== 'active') {
|
||||
throw createError({ statusCode: 403, message: '账号已被禁用或限制访问' })
|
||||
}
|
||||
|
||||
let verified = false
|
||||
|
||||
if (type === 'totp') {
|
||||
const identity = await db.query.userIdentities.findFirst({
|
||||
where: and(eq(userIdentities.userId, targetUserId), eq(userIdentities.provider, 'totp'))
|
||||
})
|
||||
if (!identity) {
|
||||
throw createError({ statusCode: 400, message: '未开启TOTP验证' })
|
||||
}
|
||||
verified = authenticator.check(code, identity.providerUserId)
|
||||
|
||||
if (!verified) {
|
||||
throw createError({ statusCode: 400, message: '动态验证码错误' })
|
||||
}
|
||||
} else if (type === 'email') {
|
||||
const stored = twoFactorCodes.get(targetUserId)
|
||||
|
||||
if (!stored) {
|
||||
throw createError({ statusCode: 400, message: '验证码已过期或不存在' })
|
||||
}
|
||||
|
||||
if (stored.expiresAt <= Date.now()) {
|
||||
twoFactorCodes.delete(targetUserId)
|
||||
throw createError({ statusCode: 400, message: '验证码已过期' })
|
||||
}
|
||||
|
||||
// 检查尝试次数
|
||||
if (stored.attempts >= 5) {
|
||||
twoFactorCodes.delete(targetUserId)
|
||||
throw createError({ statusCode: 400, message: '验证尝试次数过多,请重新获取' })
|
||||
}
|
||||
|
||||
if (stored.code === code) {
|
||||
verified = true
|
||||
twoFactorCodes.delete(targetUserId) // 验证成功后删除
|
||||
} else {
|
||||
// 增加尝试次数
|
||||
stored.attempts++
|
||||
twoFactorCodes.set(targetUserId, stored)
|
||||
throw createError({ statusCode: 400, message: `验证码错误,剩余尝试次数:${5 - stored.attempts}` })
|
||||
}
|
||||
} else {
|
||||
throw createError({ statusCode: 400, message: '不支持的验证类型' })
|
||||
}
|
||||
|
||||
// 验证通过,更新登录信息
|
||||
const clientIp = getClientIP(event)
|
||||
|
||||
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 authToken = JWTEnhanced.generateToken(user.id, user.role)
|
||||
|
||||
const isSecure =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
|
||||
|
||||
setCookie(event, 'auth-token', authToken, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
grade: user.grade,
|
||||
class: user.class,
|
||||
role: user.role,
|
||||
needsPasswordChange: !user.passwordChangedAt,
|
||||
has2FA: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import { parseState, getRedirectUri } from '~~/server/utils/oauth'
|
||||
import { generateBindingToken } from '~~/server/utils/oauth-token'
|
||||
import { db, eq, userIdentities } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
|
||||
import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const provider = getRouterParam(event, 'provider')
|
||||
const query = getQuery(event)
|
||||
const code = query.code as string
|
||||
const stateStr = query.state as string
|
||||
|
||||
if (!provider) {
|
||||
throw createError({ statusCode: 400, message: 'Missing provider' })
|
||||
}
|
||||
|
||||
if (!code || !stateStr) {
|
||||
throw createError({ statusCode: 400, message: 'Missing code or state' })
|
||||
}
|
||||
|
||||
// 1. 验证 State
|
||||
const csrfCookie = getCookie(event, 'oauth_csrf')
|
||||
|
||||
// 获取 Origin
|
||||
const headers = getRequestHeaders(event)
|
||||
const protocol = headers['x-forwarded-proto'] || 'http'
|
||||
const host = headers['host']
|
||||
const origin = `${protocol}://${host}`
|
||||
|
||||
const state = parseState(stateStr, origin, csrfCookie)
|
||||
if (!state) {
|
||||
throw createError({ statusCode: 400, message: 'Invalid or expired state' })
|
||||
}
|
||||
|
||||
// 清除 CSRF cookie
|
||||
deleteCookie(event, 'oauth_csrf')
|
||||
|
||||
const strategy = getOAuthStrategy(provider)
|
||||
const redirectUri = getRedirectUri(provider)
|
||||
|
||||
// 2. 使用 Code 换取 Token
|
||||
let accessToken = ''
|
||||
try {
|
||||
accessToken = await strategy.exchangeToken(code, redirectUri)
|
||||
} catch (e: any) {
|
||||
console.error(`[OAuth] ${provider} token exchange failed:`, e.message)
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=TOKEN_EXCHANGE_FAILED&message=${encodeURIComponent('授权失败,无法获取访问令牌')}`
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 获取用户信息
|
||||
let userInfo
|
||||
try {
|
||||
userInfo = await strategy.getUserInfo(accessToken)
|
||||
} catch (e: any) {
|
||||
console.error(`[OAuth] ${provider} get user info failed:`, e.message)
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=USER_INFO_FAILED&message=${encodeURIComponent('获取用户信息失败')}`
|
||||
)
|
||||
}
|
||||
|
||||
const providerUserId = userInfo.id
|
||||
const providerUsername = userInfo.username
|
||||
|
||||
return handleUserLoginOrBind(event, provider, providerUserId, providerUsername)
|
||||
})
|
||||
|
||||
async function handleUserLoginOrBind(
|
||||
event: any,
|
||||
provider: string,
|
||||
providerUserId: string,
|
||||
providerUsername: string
|
||||
) {
|
||||
// 4. 检查是否已登录(绑定模式)
|
||||
const authToken = getCookie(event, 'auth-token')
|
||||
let currentUser: any = null
|
||||
if (authToken) {
|
||||
try {
|
||||
const payload = JWTEnhanced.verifyToken(authToken)
|
||||
currentUser = payload
|
||||
} catch (e) {
|
||||
// Token 无效或已过期,忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 检查身份关联
|
||||
const existingIdentity = await db.query.userIdentities.findFirst({
|
||||
where: (t, { eq, and }) => and(eq(t.provider, provider), eq(t.providerUserId, providerUserId)),
|
||||
with: { user: true }
|
||||
})
|
||||
|
||||
// 如果用户已登录,则是绑定操作
|
||||
if (currentUser) {
|
||||
if (existingIdentity) {
|
||||
// 已经被绑定
|
||||
if (existingIdentity.userId === currentUser.userId) {
|
||||
// 已经被当前用户绑定
|
||||
return sendRedirect(event, '/account?message=' + encodeURIComponent('账号已绑定'))
|
||||
} else {
|
||||
// 已经被其他用户绑定
|
||||
return sendRedirect(event, '/account?error=' + encodeURIComponent('该账号已被其他用户绑定'))
|
||||
}
|
||||
} else {
|
||||
// 未被绑定,直接绑定到当前用户
|
||||
await db.insert(userIdentities).values({
|
||||
userId: currentUser.userId,
|
||||
provider: provider,
|
||||
providerUserId: providerUserId,
|
||||
providerUsername: providerUsername,
|
||||
createdAt: new Date()
|
||||
})
|
||||
return sendRedirect(event, '/account?message=' + encodeURIComponent('绑定成功'))
|
||||
}
|
||||
}
|
||||
|
||||
// 未登录,则是登录或新绑定流程
|
||||
if (existingIdentity && existingIdentity.user) {
|
||||
// 检查用户状态
|
||||
const user = existingIdentity.user
|
||||
if (user.status === 'withdrawn') {
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=ACCOUNT_WITHDRAWN&message=${encodeURIComponent('账号已注销')}`
|
||||
)
|
||||
}
|
||||
if (user.status === 'graduate') {
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=ACCOUNT_GRADUATED&message=${encodeURIComponent('该账号已毕业,限制访问')}`
|
||||
)
|
||||
}
|
||||
if (isUserBlocked(user.id)) {
|
||||
const remaining = getUserBlockRemainingTime(user.id)
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/auth/error?code=ACCOUNT_BLOCKED&message=${encodeURIComponent(`账户处于风险控制期,请在 ${remaining} 分钟后重试`)}`
|
||||
)
|
||||
}
|
||||
|
||||
// 登录
|
||||
const token = JWTEnhanced.generateToken(existingIdentity.user.id, existingIdentity.user.role)
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
return sendRedirect(event, '/')
|
||||
} else {
|
||||
// 绑定
|
||||
const bindingToken = generateBindingToken({
|
||||
provider: provider,
|
||||
providerUserId,
|
||||
providerUsername
|
||||
})
|
||||
|
||||
// 将绑定令牌存入 cookie
|
||||
setCookie(event, 'binding-token', bindingToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10, // 10分钟
|
||||
path: '/'
|
||||
})
|
||||
|
||||
return sendRedirect(
|
||||
event,
|
||||
`/login?action=bind&provider=${provider}&username=${encodeURIComponent(providerUsername)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { generateState, getRedirectUri } from '~~/server/utils/oauth'
|
||||
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const provider = getRouterParam(event, 'provider')
|
||||
if (!provider) {
|
||||
throw createError({ statusCode: 400, message: 'Missing provider' })
|
||||
}
|
||||
|
||||
const strategy = getOAuthStrategy(provider)
|
||||
|
||||
// 获取 Origin
|
||||
const headers = getRequestHeaders(event)
|
||||
const protocol = headers['x-forwarded-proto'] || 'http'
|
||||
const host = headers['host']
|
||||
const origin = `${protocol}://${host}`
|
||||
|
||||
const redirectUri = getRedirectUri(provider)
|
||||
|
||||
const { state, csrf } = generateState(origin, provider)
|
||||
|
||||
setCookie(event, 'oauth_csrf', csrf, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10, // 10分钟
|
||||
path: '/'
|
||||
})
|
||||
|
||||
const url = strategy.getAuthorizeUrl(redirectUri, state)
|
||||
|
||||
return sendRedirect(event, url)
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, eq, users, userIdentities } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { verifyBindingToken } from '~~/server/utils/oauth-token'
|
||||
import {
|
||||
isAccountLocked,
|
||||
recordLoginFailure,
|
||||
recordLoginSuccess
|
||||
} from '~~/server/services/securityService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { username, password } = body
|
||||
const bindingToken = getCookie(event, 'binding-token')
|
||||
|
||||
if (!bindingToken) {
|
||||
throw createError({ statusCode: 400, message: '绑定会话已过期,请重新通过第三方登录发起' })
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = verifyBindingToken(bindingToken)
|
||||
} catch (e) {
|
||||
deleteCookie(event, 'binding-token')
|
||||
throw createError({ statusCode: 400, message: '无效的绑定令牌' })
|
||||
}
|
||||
|
||||
const clientIp = getClientIP(event)
|
||||
|
||||
// 验证用户凭据
|
||||
if (isAccountLocked(username)) {
|
||||
throw createError({ statusCode: 423, message: '账户已被锁定,请稍后重试' })
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.username, username)
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
recordLoginFailure(username, clientIp)
|
||||
throw createError({ statusCode: 401, message: '用户名或密码错误' })
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password)
|
||||
if (!valid) {
|
||||
recordLoginFailure(username, clientIp)
|
||||
throw createError({ statusCode: 401, message: '用户名或密码错误' })
|
||||
}
|
||||
|
||||
// 绑定
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const existing = await tx.query.userIdentities.findFirst({
|
||||
where: (t, { eq, and }) =>
|
||||
and(eq(t.provider, payload.provider), eq(t.providerUserId, payload.providerUserId))
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
// 已经被绑定,不需要再次插入
|
||||
return
|
||||
}
|
||||
|
||||
await tx.insert(userIdentities).values({
|
||||
userId: user.id,
|
||||
provider: payload.provider,
|
||||
providerUserId: payload.providerUserId,
|
||||
providerUsername: payload.providerUsername
|
||||
})
|
||||
})
|
||||
} catch (e: any) {
|
||||
// 如果用户尝试再次绑定相同的账户,处理唯一约束违规
|
||||
if (e.code === '23505') {
|
||||
// Postgres 唯一性冲突
|
||||
// 这意味着身份已经绑定,这是正常情况。
|
||||
// 我们可以继续执行登录流程。
|
||||
} else {
|
||||
// 绑定失败,清除 cookie 防止重放,或者保留允许重试?
|
||||
// 如果是系统错误,保留 cookie 可能更好。
|
||||
// 但如果是逻辑错误,应该清除。
|
||||
// 这里我们选择抛出错误,前端处理。
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
recordLoginSuccess(username, clientIp)
|
||||
|
||||
// 登录
|
||||
const token = JWTEnhanced.generateToken(user.id, user.role)
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
// 清除绑定令牌
|
||||
deleteCookie(event, 'binding-token')
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { recordLoginFailure, recordLoginSuccess } from '../../services/securityService'
|
||||
import { updateUserPassword } from '../../services/userService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 验证用户身份
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: '未授权'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
if (!body.currentPassword || !body.newPassword) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: '当前密码和新密码都是必需的'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 查询用户详细信息
|
||||
const userDetailsResult = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
password: users.password
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.limit(1)
|
||||
|
||||
const userDetails = userDetailsResult[0]
|
||||
|
||||
if (!userDetails) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '用户不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证当前密码
|
||||
const isPasswordValid = await bcrypt.compare(body.currentPassword, userDetails.password)
|
||||
|
||||
if (!isPasswordValid) {
|
||||
// 记录安全事件
|
||||
const clientIp = getClientIP(event)
|
||||
recordLoginFailure(userDetails.username, clientIp)
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: '当前密码不正确'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查新密码是否与当前密码相同
|
||||
const isSamePassword = await bcrypt.compare(body.newPassword, userDetails.password)
|
||||
if (isSamePassword) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: '新密码不能与当前密码相同'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
await updateUserPassword(user.id, body.newPassword)
|
||||
|
||||
// 记录成功的密码修改
|
||||
const clientIp = getClientIP(event)
|
||||
recordLoginSuccess(userDetails.username, clientIp)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '密码修改成功'
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 已格式化的错误直接抛出
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 记录错误信息
|
||||
console.error('修改密码过程中发生未处理的错误:', error)
|
||||
|
||||
// 创建错误响应
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: '修改密码失败: ' + (error.message || '未知错误')
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { sendEmailVerificationCode } from '~~/server/api/user/email/send-code.post'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (getMethod(event) !== 'POST') {
|
||||
throw createError({ statusCode: 405, message: '方法不被允许' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const emailRaw = (body?.email || '').toString().trim().toLowerCase()
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
if (!emailRaw || !emailRegex.test(emailRaw)) {
|
||||
throw createError({ statusCode: 400, message: '请输入有效的邮箱地址' })
|
||||
}
|
||||
|
||||
// 确认邮箱未被占用
|
||||
const existing = await db.select().from(users).where(eq(users.email, emailRaw)).limit(1)
|
||||
|
||||
// 检查是否为已登录用户的修改邮箱请求
|
||||
const user = event.context.user
|
||||
if (existing.length > 0) {
|
||||
if (!user || existing[0].id !== user.id) {
|
||||
throw createError({ statusCode: 400, message: '该邮箱已被其他用户绑定' })
|
||||
}
|
||||
}
|
||||
|
||||
// 获取客户端IP地址
|
||||
const clientIP = getClientIP(event)
|
||||
|
||||
// 发送邮件验证码
|
||||
await sendEmailVerificationCode(user?.id || null, emailRaw, user?.name || '新用户', clientIP)
|
||||
|
||||
return { success: true, message: '验证码已发送,请查收邮箱' }
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { db, eq, and, userIdentities } from '~/drizzle/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
const identities = await db.query.userIdentities.findMany({
|
||||
where: eq(userIdentities.userId, user.id),
|
||||
columns: {
|
||||
id: true, // 需要 id 以便单独删除
|
||||
provider: true,
|
||||
providerUsername: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
||||
return identities.map(identity => {
|
||||
if (identity.provider === 'webauthn') {
|
||||
try {
|
||||
const data = JSON.parse(identity.providerUsername)
|
||||
return {
|
||||
...identity,
|
||||
providerUsername: data.label || 'WebAuthn 设备',
|
||||
// 可以在这里把 label 改为 userFriendlyName
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
...identity,
|
||||
providerUsername: '未知设备'
|
||||
}
|
||||
}
|
||||
}
|
||||
return identity
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, eq, users, userIdentities, and } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import {
|
||||
getAccountLockRemainingTime,
|
||||
getIPBlockRemainingTime,
|
||||
isAccountLocked,
|
||||
isIPBlocked,
|
||||
recordLoginFailure,
|
||||
recordLoginSuccess,
|
||||
recordAccountIpLogin,
|
||||
blockUser,
|
||||
getUserBlockRemainingTime
|
||||
} from '../../services/securityService'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
const clientIp = getClientIP(event)
|
||||
|
||||
if (!body.username || !body.password) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '账号名和密码不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.error('JWT_SECRET environment variable is not set')
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '服务器配置错误'
|
||||
})
|
||||
}
|
||||
|
||||
// 数据库连接检查 - 使用简单的查询测试连接
|
||||
try {
|
||||
await db.select().from(users).limit(1)
|
||||
} catch (error) {
|
||||
console.error('Database connection error:', error)
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
message: '数据库服务暂时不可用'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查IP是否被限制
|
||||
if (isIPBlocked(clientIp)) {
|
||||
const remainingTime = getIPBlockRemainingTime(clientIp)
|
||||
throw createError({
|
||||
statusCode: 423,
|
||||
message: `您的IP地址已被限制访问,请在 ${remainingTime} 分钟后重试`
|
||||
})
|
||||
}
|
||||
|
||||
// 检查账户是否被锁定
|
||||
if (isAccountLocked(body.username)) {
|
||||
const remainingTime = getAccountLockRemainingTime(body.username)
|
||||
throw createError({
|
||||
statusCode: 423,
|
||||
message: `账户已被锁定,请在 ${remainingTime} 分钟后重试`
|
||||
})
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const userResult = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
grade: users.grade,
|
||||
class: users.class,
|
||||
password: users.password,
|
||||
role: users.role,
|
||||
lastLogin: users.lastLogin,
|
||||
lastLoginIp: users.lastLoginIp,
|
||||
passwordChangedAt: users.passwordChangedAt,
|
||||
status: users.status,
|
||||
email: users.email,
|
||||
emailVerified: users.emailVerified
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.username, body.username))
|
||||
.limit(1)
|
||||
|
||||
const user = userResult[0] || null
|
||||
|
||||
if (!user) {
|
||||
// 记录登录失败(用户不存在)
|
||||
recordLoginFailure(body.username, clientIp)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '用户不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = await bcrypt.compare(body.password, user.password)
|
||||
if (!isPasswordValid) {
|
||||
// 记录登录失败(密码错误)
|
||||
recordLoginFailure(body.username, clientIp)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '密码不正确'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查用户状态 (移到2FA之前,防止已注销用户进行2FA验证)
|
||||
if (user.status === 'withdrawn') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '该账号已注销'
|
||||
})
|
||||
} else if (user.status === 'graduate') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '该账号已毕业,限制访问'
|
||||
})
|
||||
} else if (user.status === 'banned') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '该账号已被封禁'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否开启2FA
|
||||
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,
|
||||
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}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
requires2FA: true,
|
||||
userId: user.id,
|
||||
methods,
|
||||
maskedEmail,
|
||||
tempToken
|
||||
}
|
||||
}
|
||||
|
||||
recordLoginSuccess(body.username, clientIp)
|
||||
|
||||
const ipSwitchExceeded = recordAccountIpLogin(body.username, clientIp)
|
||||
if (ipSwitchExceeded) {
|
||||
blockUser(user.id)
|
||||
const ipRemain = getIPBlockRemainingTime(clientIp)
|
||||
const userRemain = getUserBlockRemainingTime(user.id)
|
||||
throw createError({
|
||||
statusCode: 423,
|
||||
message: `检测到同一账号短期多IP登录,当前IP限制 ${ipRemain} 分钟,账户保护 ${userRemain} 分钟`
|
||||
})
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
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))
|
||||
|
||||
// 生成JWT
|
||||
const token = JWTEnhanced.generateToken(user.id, user.role)
|
||||
|
||||
// 自动判断是否需要secure
|
||||
const isSecure =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
|
||||
|
||||
// 设置cookie
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7天
|
||||
path: '/'
|
||||
})
|
||||
|
||||
const processingTime = Date.now() - startTime
|
||||
console.log(`Login for ${user.username} processed in ${processingTime}ms`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
grade: user.grade,
|
||||
class: user.class,
|
||||
studentId: user.studentId,
|
||||
role: user.role,
|
||||
needsPasswordChange: !user.passwordChangedAt
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorTime = Date.now() - startTime
|
||||
console.error(`Login error after ${errorTime}ms:`, error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '登录过程中发生未知错误'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
console.log('[Auth] User logout requested')
|
||||
|
||||
// 获取当前用户信息(如果存在)
|
||||
const user = event.context.user
|
||||
|
||||
// 清除用户认证缓存
|
||||
if (user) {
|
||||
try {
|
||||
const { cache } = await import('~~/server/utils/cache-helpers')
|
||||
await cache.delete(`auth:user:${user.id}`)
|
||||
console.log(`[Cache] 用户认证缓存已清除(用户登出): ${user.id}`)
|
||||
} catch (cacheError) {
|
||||
console.warn('[Cache] 清除用户认证缓存失败:', cacheError)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除cookie
|
||||
const isSecure =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
|
||||
setCookie(event, 'auth-token', '', {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '登出成功'
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Logout error:', error)
|
||||
|
||||
// 出错时也要清除cookie
|
||||
try {
|
||||
setCookie(event, 'auth-token', '', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
path: '/'
|
||||
})
|
||||
} catch (cookieError) {
|
||||
console.error('Failed to clear cookie:', cookieError)
|
||||
}
|
||||
|
||||
// 返回成功状态
|
||||
return {
|
||||
success: true,
|
||||
message: '登出成功'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, eq, users } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { emailVerificationCodes } from '~~/server/api/user/email/send-code.post'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body.username || !body.password) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '姓名/用户名和密码不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证姓名/用户名只能包含汉字
|
||||
const chineseRegex = /^[\u4e00-\u9fa5]+$/
|
||||
if (!chineseRegex.test(body.username)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '姓名/用户名只能包含汉字'
|
||||
})
|
||||
}
|
||||
|
||||
const rawEmail = typeof body.email === 'string' ? body.email.trim() : ''
|
||||
const email = rawEmail ? rawEmail.toLowerCase() : null
|
||||
|
||||
if (email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(email)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '邮箱格式不正确'
|
||||
})
|
||||
}
|
||||
|
||||
const existingEmailUser = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1)
|
||||
|
||||
if (existingEmailUser.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '邮箱已被绑定'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
if (!body.emailCode) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请提供邮箱验证码'
|
||||
})
|
||||
}
|
||||
|
||||
const storedData = emailVerificationCodes.get(email)
|
||||
if (!storedData) {
|
||||
throw createError({ statusCode: 400, message: '请先获取邮箱验证码' })
|
||||
}
|
||||
if (Date.now() > storedData.expiresAt) {
|
||||
emailVerificationCodes.delete(email)
|
||||
throw createError({ statusCode: 400, message: '验证码已过期,请重新获取' })
|
||||
}
|
||||
if (storedData.code !== body.emailCode.trim()) {
|
||||
throw createError({ statusCode: 400, message: '邮箱验证码不正确' })
|
||||
}
|
||||
}
|
||||
|
||||
// 验证用户名唯一性
|
||||
const existingUser = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.username, body.username))
|
||||
.limit(1)
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '用户名已被注册'
|
||||
})
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const saltRounds = 10
|
||||
const hashedPassword = await bcrypt.hash(body.password, saltRounds)
|
||||
|
||||
// 插入新用户
|
||||
const [newUser] = await db.insert(users).values({
|
||||
username: body.username,
|
||||
name: body.username, // 默认将昵称设置为账号名
|
||||
password: hashedPassword,
|
||||
email,
|
||||
emailVerified: !!email, // 邮箱存在则表示已验证
|
||||
grade: body.grade || null,
|
||||
class: body.class || null,
|
||||
studentId: body.studentId || null,
|
||||
role: 'USER',
|
||||
status: 'active'
|
||||
}).returning({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
role: users.role
|
||||
})
|
||||
|
||||
if (email) {
|
||||
emailVerificationCodes.delete(email)
|
||||
}
|
||||
|
||||
// 签发 JWT Token
|
||||
const token = JWTEnhanced.generateToken(newUser.id, newUser.role)
|
||||
|
||||
const isSecure =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
|
||||
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7天
|
||||
path: '/'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: newUser.id,
|
||||
username: newUser.username,
|
||||
role: newUser.role
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 检查认证
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '未授权'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
if (!body.newPassword) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '新密码不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const currentUserResult = await db.select().from(users).where(eq(users.id, user.id)).limit(1)
|
||||
const currentUser = currentUserResult[0]
|
||||
if (!currentUser) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '用户不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否需要设置初始密码
|
||||
if (currentUser.passwordChangedAt) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '您已经设置过密码,请使用修改密码功能'
|
||||
})
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
const hashedPassword = await bcrypt.hash(body.newPassword, 10)
|
||||
|
||||
// 更新密码
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
passwordChangedAt: getBeijingTime(),
|
||||
forcePasswordChange: false
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '初始密码设置成功'
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '初始密码设置失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { db, eq, and, userIdentities } from '~/drizzle/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { provider, id } = body
|
||||
|
||||
if (!provider) {
|
||||
throw createError({ statusCode: 400, message: '缺少提供商参数' })
|
||||
}
|
||||
|
||||
// 如果提供了 ID,按 ID 解绑
|
||||
if (id) {
|
||||
await db
|
||||
.delete(userIdentities)
|
||||
.where(and(eq(userIdentities.id, id), eq(userIdentities.userId, user.id)))
|
||||
} else {
|
||||
// 否则解绑该 Provider 下的所有绑定
|
||||
await db
|
||||
.delete(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, provider)))
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { executeRedisCommand, isRedisReady } from '../../utils/redis'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
// 用户认证缓存(永久缓存,登出或权限变更时主动失效)
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token =
|
||||
getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: '未提供认证令牌'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证JWT令牌
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number }
|
||||
const userId = decoded.userId
|
||||
|
||||
// 优先从Redis缓存获取用户认证状态
|
||||
if (isRedisReady()) {
|
||||
const cachedUser = await executeRedisCommand(async () => {
|
||||
const client = (await import('../../utils/redis')).getRedisClient()
|
||||
if (!client) return null
|
||||
|
||||
const cacheKey = `auth:user:${userId}`
|
||||
const userData = await client.get(cacheKey)
|
||||
|
||||
if (userData) {
|
||||
console.log(`[API] 用户认证缓存命中: ${userId}`)
|
||||
return JSON.parse(userData)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
if (cachedUser) {
|
||||
// 为缓存的用户数据添加字段
|
||||
const userWithDetails = {
|
||||
id: cachedUser.id,
|
||||
username: cachedUser.username,
|
||||
name: cachedUser.name,
|
||||
grade: cachedUser.grade,
|
||||
class: cachedUser.class,
|
||||
studentId: cachedUser.studentId,
|
||||
email: cachedUser.email,
|
||||
emailVerified: cachedUser.emailVerified,
|
||||
role: cachedUser.role,
|
||||
requirePasswordChange: cachedUser.forcePasswordChange || !cachedUser.passwordChangedAt,
|
||||
has2FA: cachedUser.identities?.some((id: any) => id.provider === 'totp') || false,
|
||||
avatar: cachedUser.identities?.find((id: any) => id.provider === 'github')?.providerUsername
|
||||
? `https://github.com/${cachedUser.identities.find((id: any) => id.provider === 'github').providerUsername}.png`
|
||||
: null
|
||||
}
|
||||
return {
|
||||
user: userWithDetails,
|
||||
valid: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中或Redis不可用,从数据库获取用户信息
|
||||
// 同时查询所有身份绑定信息
|
||||
const userResult = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
class: true,
|
||||
studentId: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
role: true,
|
||||
forcePasswordChange: true,
|
||||
passwordChangedAt: true
|
||||
},
|
||||
with: {
|
||||
identities: {
|
||||
columns: {
|
||||
provider: true,
|
||||
providerUsername: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const dbUser = userResult || null
|
||||
|
||||
if (!dbUser) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: '用户不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 构建返回的用户对象,只包含需要的字段
|
||||
const githubIdentity = dbUser.identities?.find((id: any) => id.provider === 'github')
|
||||
const user = {
|
||||
id: dbUser.id,
|
||||
username: dbUser.username,
|
||||
name: dbUser.name,
|
||||
grade: dbUser.grade,
|
||||
class: dbUser.class,
|
||||
studentId: dbUser.studentId,
|
||||
email: dbUser.email,
|
||||
emailVerified: dbUser.emailVerified,
|
||||
role: dbUser.role,
|
||||
requirePasswordChange: dbUser.forcePasswordChange || !dbUser.passwordChangedAt,
|
||||
has2FA: dbUser.identities?.some((id: any) => id.provider === 'totp') || false,
|
||||
// 动态生成 GitHub 头像 URL
|
||||
avatar: githubIdentity?.providerUsername
|
||||
? `https://github.com/${githubIdentity.providerUsername}.png`
|
||||
: null
|
||||
}
|
||||
|
||||
// 将用户认证状态缓存到Redis(如果可用)- 永久缓存
|
||||
if (isRedisReady()) {
|
||||
await executeRedisCommand(async () => {
|
||||
const client = (await import('../../utils/redis')).getRedisClient()
|
||||
if (!client) return
|
||||
|
||||
const cacheKey = `auth:user:${userId}`
|
||||
// 缓存完整的数据库用户信息,用于后续验证
|
||||
await client.set(cacheKey, JSON.stringify(dbUser))
|
||||
console.log(`[API] 用户认证状态已缓存: ${userId}`)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
valid: true
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: '令牌无效或已过期'
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { generateAuthenticationOptions } from '@simplewebauthn/server'
|
||||
import { setWebAuthnChallenge } from '~~/server/utils/webauthn-token'
|
||||
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { rpID } = getWebAuthnConfig(event)
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
userVerification: 'preferred',
|
||||
allowCredentials: [],
|
||||
})
|
||||
|
||||
setWebAuthnChallenge(event, options.challenge, 'authentication')
|
||||
|
||||
return options
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
|
||||
import { getWebAuthnChallenge, clearWebAuthnChallenge } from '~~/server/utils/webauthn-token'
|
||||
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
|
||||
import { db, eq, and, userIdentities } from '~/drizzle/db'
|
||||
import { createError, defineEventHandler, setCookie } from 'h3'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const challengeData = getWebAuthnChallenge(event)
|
||||
|
||||
if (!challengeData) {
|
||||
throw createError({ statusCode: 400, message: 'Challenge 已失效' })
|
||||
}
|
||||
|
||||
// 通过 credentialID 查找对应的用户身份
|
||||
const credentialID = body.id
|
||||
if (!credentialID) {
|
||||
throw createError({ statusCode: 400, message: '缺少 Credential ID' })
|
||||
}
|
||||
|
||||
const identity = await db.query.userIdentities.findFirst({
|
||||
where: and(eq(userIdentities.provider, 'webauthn'), eq(userIdentities.providerUserId, credentialID)),
|
||||
with: { user: true }
|
||||
})
|
||||
|
||||
if (!identity) {
|
||||
throw createError({ statusCode: 400, message: '未找到该 Passkey 关联的账号' })
|
||||
}
|
||||
|
||||
// 检查用户账号状态
|
||||
if (identity.user.status !== 'active') {
|
||||
throw createError({ statusCode: 403, message: '账号已被禁用或注销' })
|
||||
}
|
||||
|
||||
// 解析存储的凭证数据
|
||||
let credentialData
|
||||
try {
|
||||
credentialData = JSON.parse(identity.providerUsername)
|
||||
} catch {
|
||||
throw createError({ statusCode: 500, message: '凭证数据损坏' })
|
||||
}
|
||||
|
||||
const { publicKey, counter } = credentialData
|
||||
|
||||
// 验证公钥存在
|
||||
if (!publicKey) {
|
||||
throw createError({ statusCode: 500, message: '凭证数据缺失 publicKey' })
|
||||
}
|
||||
|
||||
const { rpID, origin } = getWebAuthnConfig(event)
|
||||
|
||||
// 构造 WebAuthn 凭证对象
|
||||
const credential = {
|
||||
id: credentialID,
|
||||
publicKey: Buffer.from(publicKey, 'base64url'),
|
||||
counter: Number(counter || 0),
|
||||
transports: credentialData.transports
|
||||
}
|
||||
|
||||
try {
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: body,
|
||||
expectedChallenge: challengeData.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
credential
|
||||
})
|
||||
|
||||
if (verification.verified) {
|
||||
const newCounter = verification.authenticationInfo.newCounter
|
||||
const currentCounter = Number(counter || 0)
|
||||
|
||||
// 防重放攻击:检查计数器是否递增
|
||||
if (currentCounter > 0 && newCounter <= currentCounter) {
|
||||
console.error('WebAuthn 计数器未递增:', { currentCounter, newCounter, credentialID })
|
||||
throw createError({ statusCode: 400, message: '认证无效:凭证计数器未递增' })
|
||||
}
|
||||
|
||||
credentialData.counter = newCounter
|
||||
|
||||
await db.update(userIdentities)
|
||||
.set({ providerUsername: JSON.stringify(credentialData) })
|
||||
.where(eq(userIdentities.id, identity.id))
|
||||
|
||||
// 签发登录 Token
|
||||
const token = JWTEnhanced.generateToken(identity.user.id, identity.user.role)
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
clearWebAuthnChallenge(event)
|
||||
return { success: true, redirect: '/' }
|
||||
} else {
|
||||
throw createError({ statusCode: 400, message: '验证失败' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebAuthn 登录验证失败:', error)
|
||||
const message = error instanceof Error ? error.message : '验证失败'
|
||||
throw createError({ statusCode: 400, message })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { generateRegistrationOptions } from '@simplewebauthn/server'
|
||||
import { isoUint8Array } from '@simplewebauthn/server/helpers'
|
||||
import { setWebAuthnChallenge } from '~~/server/utils/webauthn-token'
|
||||
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
|
||||
import { db, eq, and, userIdentities } from '~/drizzle/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
// 获取该用户已绑定的 WebAuthn 凭证
|
||||
const existingCredentials = await db.query.userIdentities.findMany({
|
||||
where: and(eq(userIdentities.userId, user.id), eq(userIdentities.provider, 'webauthn')),
|
||||
columns: {
|
||||
providerUserId: true,
|
||||
providerUsername: true
|
||||
}
|
||||
})
|
||||
|
||||
// 防止重复注册相同凭证,使用存储的实际 transports 信息
|
||||
const excludeCredentials = existingCredentials.map(cred => {
|
||||
let transports: string[] | undefined
|
||||
try {
|
||||
if (cred.providerUsername) {
|
||||
const data = JSON.parse(cred.providerUsername)
|
||||
transports = data.transports
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 WebAuthn 凭证数据失败:', e)
|
||||
}
|
||||
return {
|
||||
id: cred.providerUserId,
|
||||
type: 'public-key' as const,
|
||||
transports
|
||||
}
|
||||
})
|
||||
|
||||
const { rpID } = getWebAuthnConfig(event)
|
||||
const rpName = 'VoiceHub'
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userID: isoUint8Array.fromUTF8String(user.id.toString()),
|
||||
userName: user.username || user.email,
|
||||
attestationType: 'none',
|
||||
excludeCredentials,
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
})
|
||||
|
||||
// 存储注册挑战到 Cookie
|
||||
setWebAuthnChallenge(event, options.challenge, user.id.toString())
|
||||
|
||||
return options
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { verifyRegistrationResponse } from '@simplewebauthn/server'
|
||||
import { getWebAuthnChallenge, clearWebAuthnChallenge } from '~~/server/utils/webauthn-token'
|
||||
import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
|
||||
import { db, userIdentities } from '~/drizzle/db'
|
||||
|
||||
const VALID_TRANSPORTS = ['internal', 'hybrid', 'usb', 'nfc', 'ble']
|
||||
|
||||
function sanitizeTransports(transports: unknown): string[] {
|
||||
if (!Array.isArray(transports)) return []
|
||||
return transports.filter((t): t is string =>
|
||||
typeof t === 'string' && VALID_TRANSPORTS.includes(t)
|
||||
)
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const challengeData = getWebAuthnChallenge(event)
|
||||
|
||||
if (!challengeData || challengeData.userId !== user.id.toString()) {
|
||||
throw createError({ statusCode: 400, message: 'Challenge 已失效或不匹配' })
|
||||
}
|
||||
|
||||
const { rpID, origin } = getWebAuthnConfig(event)
|
||||
|
||||
try {
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: body,
|
||||
expectedChallenge: challengeData.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
})
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const { credential } = verification.registrationInfo
|
||||
|
||||
// 提取凭证数据
|
||||
const { id: credentialID, publicKey: credentialPublicKey, counter, transports } = credential
|
||||
|
||||
if (!credentialID || !credentialPublicKey) {
|
||||
throw new Error('注册信息缺少凭证 ID 或公钥')
|
||||
}
|
||||
|
||||
// 转换为 Base64URL 格式存储
|
||||
const credentialIDBase64 = credentialID
|
||||
const publicKeyBase64 = Buffer.from(credentialPublicKey).toString('base64url')
|
||||
|
||||
if (!publicKeyBase64) {
|
||||
throw new Error('生成的公钥为空,注册失败')
|
||||
}
|
||||
|
||||
// 构造凭证数据
|
||||
const credentialData = {
|
||||
label: body.label || 'WebAuthn 设备',
|
||||
publicKey: publicKeyBase64,
|
||||
counter: Number(counter),
|
||||
transports: sanitizeTransports(transports)
|
||||
}
|
||||
|
||||
// 存储到数据库
|
||||
await db.insert(userIdentities).values({
|
||||
userId: user.id,
|
||||
provider: 'webauthn',
|
||||
providerUserId: credentialIDBase64,
|
||||
providerUsername: JSON.stringify(credentialData),
|
||||
createdAt: new Date()
|
||||
})
|
||||
|
||||
clearWebAuthnChallenge(event)
|
||||
return { success: true }
|
||||
} else {
|
||||
throw createError({ statusCode: 400, message: '验证失败' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebAuthn 验证失败:', error)
|
||||
const message = error instanceof Error ? error.message : '验证失败'
|
||||
throw createError({ statusCode: 400, message })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { db, eq, and, userIdentities, sql } from '~/drizzle/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
interface RenameRequestBody {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
const body = await readBody<RenameRequestBody>(event)
|
||||
const { id, name: rawName } = body
|
||||
const name = typeof rawName === 'string' ? rawName.trim() : ''
|
||||
|
||||
if (!id || !name) {
|
||||
throw createError({ statusCode: 400, message: '参数错误' })
|
||||
}
|
||||
|
||||
if (name.length > 50) {
|
||||
throw createError({ statusCode: 400, message: '设备名称过长' })
|
||||
}
|
||||
|
||||
// 查找对应的 Passkey
|
||||
const identity = await db.query.userIdentities.findFirst({
|
||||
where: and(
|
||||
eq(userIdentities.id, id),
|
||||
eq(userIdentities.userId, user.id),
|
||||
eq(userIdentities.provider, 'webauthn')
|
||||
)
|
||||
})
|
||||
|
||||
if (!identity) {
|
||||
throw createError({ statusCode: 404, message: '未找到该设备' })
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 jsonb_set 原子更新 label,避免覆盖 concurrent login 产生的 counter 更新
|
||||
await db.update(userIdentities)
|
||||
.set({
|
||||
providerUsername: sql`jsonb_set(${userIdentities.providerUsername}::jsonb, '{label}', ${JSON.stringify(name)}::jsonb)::text`
|
||||
})
|
||||
.where(eq(userIdentities.id, id))
|
||||
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
console.error('更新设备名称失败:', e)
|
||||
throw createError({ statusCode: 500, message: '更新失败' })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user