95 lines
3.4 KiB
TypeScript
95 lines
3.4 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
||
|
||
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))
|
||
}
|
||
|
||
// 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 buildLoginRedirect(request: NextRequest) {
|
||
const { pathname } = request.nextUrl
|
||
const loginUrl = new URL('/login', request.url)
|
||
const dest = pathname + (request.nextUrl.search || '')
|
||
loginUrl.searchParams.set('redirect', dest)
|
||
return NextResponse.redirect(loginUrl)
|
||
}
|
||
|
||
export function middleware(request: NextRequest) {
|
||
const { pathname } = request.nextUrl
|
||
|
||
// 登录/退出路径 + 内部 API 放行(自有 key 认证)
|
||
if (pathname.startsWith('/login') || pathname === '/' ||
|
||
pathname === '/api/auth/login' || pathname === '/api/auth/logout' ||
|
||
pathname.startsWith('/api/internal/')) {
|
||
return NextResponse.next()
|
||
}
|
||
|
||
// API Key 认证(外部系统调用)
|
||
const authHeader = request.headers.get('authorization')
|
||
if (authHeader?.startsWith('Bearer ak_')) {
|
||
const key = authHeader.slice(7)
|
||
if (verifyApiKey(key)) return NextResponse.next()
|
||
// 无效 key:API 路由返回 401
|
||
if (pathname.startsWith('/api/')) {
|
||
return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||
}
|
||
}
|
||
|
||
// 优先检查 tlyq_session(共享 JWT)
|
||
const sharedToken = request.cookies.get('tlyq_session')?.value
|
||
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
|
||
if (isValidPayload(sharedPayload)) {
|
||
const response = pathname.startsWith('/api/') ? NextResponse.next() : NextResponse.next()
|
||
response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), {
|
||
httpOnly: true, sameSite: 'lax', path: '/',
|
||
})
|
||
return response
|
||
}
|
||
|
||
// 回退 session_issue(本地 JWT)
|
||
const localToken = request.cookies.get('session_issue')?.value
|
||
const localPayload = localToken ? decodeJwtPayload(localToken) : null
|
||
|
||
if (pathname.startsWith('/api/')) {
|
||
if (!isValidPayload(localPayload)) {
|
||
return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||
}
|
||
return NextResponse.next()
|
||
}
|
||
|
||
if (!isValidPayload(localPayload)) {
|
||
const response = buildLoginRedirect(request)
|
||
if (localToken) response.cookies.delete('session_issue')
|
||
return response
|
||
}
|
||
|
||
const response = NextResponse.next()
|
||
response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
|
||
httpOnly: true, sameSite: 'lax', path: '/',
|
||
})
|
||
return response
|
||
}
|
||
|
||
export const config = {
|
||
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
|
||
}
|