232 lines
6.4 KiB
TypeScript
232 lines
6.4 KiB
TypeScript
import { JWTEnhanced } from '../utils/jwt-enhanced'
|
|
import { db, users } from '~/drizzle/db'
|
|
import { eq } from 'drizzle-orm'
|
|
import { isUserBlocked, getUserBlockRemainingTime } from '../services/securityService'
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
// 清除用户上下文
|
|
if (event.context.user) {
|
|
delete event.context.user
|
|
}
|
|
|
|
const url = getRequestURL(event)
|
|
const pathname = url.pathname
|
|
|
|
// 跳过非API路由
|
|
if (!pathname.startsWith('/api/')) {
|
|
return
|
|
}
|
|
|
|
// 公共API路径
|
|
const publicApiPaths = [
|
|
'/api/auth/login',
|
|
'/api/auth/bind', // 账号绑定
|
|
'/api/auth/verify', // verify端点自行处理token验证
|
|
'/api/semesters/current',
|
|
'/api/play-times',
|
|
'/api/schedules/public',
|
|
'/api/songs/count',
|
|
'/api/songs/public',
|
|
'/api/site-config',
|
|
'/api/proxy/', // 代理API路径,用于图片代理等功能
|
|
'/api/bilibili/', // 哔哩哔哩相关API
|
|
'/api/api-enhanced/', // 网易云音乐API代理路径
|
|
'/api/native-api/', // Native Music 集成API
|
|
'/api/system/location', // 系统位置检测API
|
|
'/api/open/', // 开放API路径,由api-auth中间件处理认证
|
|
'/api/auth/webauthn/login', // WebAuthn 登录接口
|
|
'/api/music/state', // 音乐状态同步
|
|
'/api/music/websocket' // WebSocket 连接
|
|
]
|
|
|
|
// 公共路径跳过认证检查
|
|
if (publicApiPaths.some((path) => pathname.startsWith(path))) {
|
|
return
|
|
}
|
|
|
|
// 动态判断 OAuth 路径
|
|
// 允许 /api/auth/[provider] 和 /api/auth/[provider]/callback
|
|
// 但排除已知的受保护/特定 Auth 端点
|
|
if (pathname.startsWith('/api/auth/')) {
|
|
const segments = pathname.split('/')
|
|
// segments: ['', 'api', 'auth', 'provider', 'callback'?]
|
|
const provider = segments[3]
|
|
|
|
// 已知的受保护或非OAuth的Auth端点
|
|
const nonOAuthEndpoints = [
|
|
'identities',
|
|
'logout',
|
|
'change-password',
|
|
'set-initial-password',
|
|
'unbind',
|
|
// login, bind, verify 已经在 publicApiPaths 中处理,这里列出是为了完整性或防止意外匹配
|
|
'login',
|
|
'bind',
|
|
'verify',
|
|
'webauthn'
|
|
]
|
|
|
|
if (provider && !nonOAuthEndpoints.includes(provider)) {
|
|
// 这是一个 OAuth 提供商路径 (例如 /api/auth/casdoor)
|
|
return
|
|
}
|
|
}
|
|
|
|
// 从请求头或cookie获取token
|
|
let token: string | null = null
|
|
const authHeader = getRequestHeader(event, 'authorization')
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
token = authHeader.substring(7)
|
|
}
|
|
|
|
if (!token) {
|
|
token = getCookie(event, 'auth-token') || null
|
|
}
|
|
|
|
// 受保护路由缺少token时返回401错误
|
|
if (!token) {
|
|
return sendError(
|
|
event,
|
|
createError({
|
|
statusCode: 401,
|
|
message: '未授权访问:缺少有效的认证信息'
|
|
})
|
|
)
|
|
}
|
|
|
|
try {
|
|
// 验证token并自动续期
|
|
const { valid, payload, newToken } = JWTEnhanced.verifyAndRefresh(token)
|
|
|
|
if (!valid || !payload) {
|
|
throw new Error('Token无效')
|
|
}
|
|
|
|
// 如果生成了新token,更新cookie
|
|
if (newToken) {
|
|
const isSecure =
|
|
getRequestURL(event).protocol === 'https:' ||
|
|
getRequestHeader(event, 'x-forwarded-proto') === 'https'
|
|
setCookie(event, 'auth-token', newToken, {
|
|
httpOnly: true,
|
|
secure: isSecure,
|
|
sameSite: 'lax',
|
|
maxAge: 60 * 60 * 24 * 7, // 7天
|
|
path: '/'
|
|
})
|
|
}
|
|
|
|
const decoded = payload
|
|
const userResult = await db
|
|
.select({
|
|
id: users.id,
|
|
username: users.username,
|
|
name: users.name,
|
|
grade: users.grade,
|
|
class: users.class,
|
|
role: users.role,
|
|
status: users.status,
|
|
passwordChangedAt: users.passwordChangedAt
|
|
})
|
|
.from(users)
|
|
.where(eq(users.id, decoded.userId))
|
|
.limit(1)
|
|
|
|
const user = userResult[0] || null
|
|
|
|
// 用户不存在或状态异常时token无效
|
|
if (!user || user.status !== 'active') {
|
|
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: '/'
|
|
})
|
|
|
|
const errorMessage = !user
|
|
? '用户不存在,请重新登录'
|
|
: user.status === 'withdrawn'
|
|
? '该账号已注销'
|
|
: user.status === 'graduate'
|
|
? '该账号已毕业,限制访问'
|
|
: '该账号已被禁用'
|
|
|
|
return sendError(
|
|
event,
|
|
createError({
|
|
statusCode: 401,
|
|
message: errorMessage
|
|
})
|
|
)
|
|
}
|
|
|
|
// 检查token是否在密码修改之前签发(强制旧token失效)
|
|
if (user.passwordChangedAt && decoded.iat) {
|
|
const passwordChangedTime = Math.floor(new Date(user.passwordChangedAt).getTime() / 1000)
|
|
if (decoded.iat < passwordChangedTime) {
|
|
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 sendError(
|
|
event,
|
|
createError({
|
|
statusCode: 401,
|
|
message: '密码已修改,请重新登录',
|
|
data: { invalidToken: true, passwordChanged: true }
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
event.context.user = user
|
|
|
|
if (isUserBlocked(user.id)) {
|
|
delete event.context.user
|
|
const remaining = getUserBlockRemainingTime(user.id)
|
|
return sendError(
|
|
event,
|
|
createError({
|
|
statusCode: 401,
|
|
message: `账户处于风险控制期,请在 ${remaining} 分钟后重试`
|
|
})
|
|
)
|
|
}
|
|
|
|
// 检查管理员专用路由
|
|
if (
|
|
pathname.startsWith('/api/admin') &&
|
|
!['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)
|
|
) {
|
|
return sendError(
|
|
event,
|
|
createError({
|
|
statusCode: 403,
|
|
message: '需要管理员权限'
|
|
})
|
|
)
|
|
}
|
|
} catch (error: any) {
|
|
// 处理JWT验证错误
|
|
return sendError(
|
|
event,
|
|
createError({
|
|
statusCode: 401,
|
|
message: `认证失败: ${error.message}`,
|
|
data: { invalidToken: true }
|
|
})
|
|
)
|
|
}
|
|
})
|