94 lines
3.6 KiB
TypeScript
94 lines
3.6 KiB
TypeScript
import { NextResponse } from 'next/server'
|
||
import type { NextRequest } from 'next/server'
|
||
|
||
// API Key 验证:检查 ALLOWED_API_KEYS 环境变量(逗号分隔明文 key)
|
||
// 注意:middleware 运行在 Edge Runtime,不能使用 better-sqlite3 等 Node.js 原生模块
|
||
// DB 级别的 key 验证在 route handler 中进行(auth.ts verifyApiKey)
|
||
function verifyApiKey(key: string): boolean {
|
||
if (!key.startsWith('ak_')) return false
|
||
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
|
||
if (!allowedKeys) return false
|
||
return allowedKeys.split(',').map(k => k.trim()).includes(key)
|
||
}
|
||
|
||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||
try {
|
||
const parts = token.split('.')
|
||
if (parts.length !== 3) return null
|
||
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||
while (payload.length % 4) payload += '='
|
||
return JSON.parse(atob(payload))
|
||
} catch { return null }
|
||
}
|
||
|
||
function isValidPayload(payload: Record<string, unknown> | null): boolean {
|
||
if (!payload) return false
|
||
return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000))
|
||
}
|
||
|
||
export function middleware(request: NextRequest) {
|
||
const { pathname } = request.nextUrl
|
||
|
||
// 登录/退出路径 + 内部 API 放行(自有 key 认证)
|
||
if (pathname === '/login' || pathname.startsWith('/api/auth/login') || pathname === '/api/auth/logout' || pathname.startsWith('/api/internal/')) {
|
||
return NextResponse.next()
|
||
}
|
||
|
||
// API 路由:检查 Bearer API Key 或 session cookie
|
||
if (pathname.startsWith('/api/')) {
|
||
const authHeader = request.headers.get('authorization')
|
||
if (authHeader?.startsWith('Bearer ak_')) {
|
||
if (verifyApiKey(authHeader.slice(7))) return NextResponse.next()
|
||
return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||
}
|
||
|
||
const sharedToken = request.cookies.get('tlyq_session')?.value
|
||
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
|
||
if (isValidPayload(sharedPayload)) return NextResponse.next()
|
||
|
||
const localToken = request.cookies.get('session_assets')?.value
|
||
const localPayload = localToken ? decodeJwtPayload(localToken) : null
|
||
if (isValidPayload(localPayload)) return NextResponse.next()
|
||
|
||
return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||
}
|
||
|
||
// 页面路由:优先检查 tlyq_session(共享 JWT),回退 session_assets(本地 JWT)
|
||
const sharedToken = request.cookies.get('tlyq_session')?.value
|
||
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
|
||
|
||
if (isValidPayload(sharedPayload)) {
|
||
const response = NextResponse.next()
|
||
response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), {
|
||
httpOnly: true,
|
||
sameSite: 'lax',
|
||
path: '/',
|
||
})
|
||
return response
|
||
}
|
||
|
||
const localToken = request.cookies.get('session_assets')?.value
|
||
const localPayload = localToken ? decodeJwtPayload(localToken) : null
|
||
|
||
if (isValidPayload(localPayload)) {
|
||
const response = NextResponse.next()
|
||
response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
|
||
httpOnly: true,
|
||
sameSite: 'lax',
|
||
path: '/',
|
||
})
|
||
return response
|
||
}
|
||
|
||
// 未认证 → 重定向登录页
|
||
const loginUrl = new URL('/login', request.url)
|
||
const dest = pathname + (request.nextUrl.search || '')
|
||
loginUrl.searchParams.set('redirect', dest)
|
||
const response = NextResponse.redirect(loginUrl)
|
||
if (sharedToken) response.cookies.delete('tlyq_session')
|
||
if (localToken) response.cookies.delete('session_assets')
|
||
return response
|
||
}
|
||
|
||
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] }
|