126 lines
5.0 KiB
TypeScript
126 lines
5.0 KiB
TypeScript
// shared/lib/auth/middleware-v2.ts — V2 middleware 工厂(步骤 1a 新增,旧 middleware.ts 不动)
|
||
// 与 V1 的关键差异:单 cookie 模型、Edge 验签+iss 校验、无 admin 路径检查(鉴权下沉到 layout/API)
|
||
import { NextResponse, type NextRequest } from 'next/server'
|
||
import { verifyJwtEdge } from './jwt-edge'
|
||
import type { MiddlewareV2Config } from './types-v2'
|
||
|
||
// Edge Runtime 兼容的 JWT payload 解码(不验签)
|
||
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))
|
||
}
|
||
|
||
function noCache(response: NextResponse): NextResponse {
|
||
response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||
return response
|
||
}
|
||
|
||
/** 创建 V2 middleware(单 cookie 模型,Edge 验签) */
|
||
export function createMiddlewareV2(cfg: MiddlewareV2Config) {
|
||
const publicPaths = cfg.publicPaths || ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico']
|
||
const allowedIssuers = cfg.allowedIssuers
|
||
const enableApiKey = cfg.enableApiKey || false
|
||
|
||
return async function middleware(request: NextRequest) {
|
||
const { pathname } = request.nextUrl
|
||
|
||
// 1. 公开路径放行
|
||
if (publicPaths.some(p => pathname.startsWith(p))) {
|
||
if (pathname === '/login') {
|
||
const token = request.cookies.get('tlyq_session')?.value
|
||
const payload = token ? decodeJwtPayload(token) : null
|
||
if (isValidPayload(payload)) {
|
||
return NextResponse.redirect(new URL('/', request.nextUrl.origin))
|
||
}
|
||
}
|
||
return NextResponse.next()
|
||
}
|
||
|
||
// 2. API Key 认证(可选,迁移期间保留)
|
||
if (enableApiKey) {
|
||
const authHeader = request.headers.get('authorization')
|
||
if (authHeader?.startsWith('Bearer ak_')) {
|
||
const key = authHeader.slice(7)
|
||
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
|
||
if (allowedKeys && allowedKeys.split(',').map(k => k.trim()).includes(key)) {
|
||
return NextResponse.next()
|
||
}
|
||
if (pathname.startsWith('/api/')) {
|
||
return NextResponse.json({ error: '无效的 API Key' }, { status: 401 })
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 验证 tlyq_session
|
||
const token = request.cookies.get('tlyq_session')?.value
|
||
if (!token) {
|
||
if (pathname.startsWith('/api/')) {
|
||
return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||
}
|
||
return noCache(NextResponse.redirect(new URL('/login', request.nextUrl.origin)))
|
||
}
|
||
|
||
// 4. 提取 iss → 验签(Edge Runtime)
|
||
const payload = decodeJwtPayload(token)
|
||
if (!payload || !isValidPayload(payload)) {
|
||
if (pathname.startsWith('/api/')) {
|
||
return NextResponse.json({ error: '登录已过期' }, { status: 401 })
|
||
}
|
||
const loginUrl = new URL('/login', request.nextUrl.origin)
|
||
const response = NextResponse.redirect(loginUrl)
|
||
response.cookies.delete('tlyq_session')
|
||
return response
|
||
}
|
||
|
||
const iss = payload.iss as string | undefined
|
||
|
||
// allowedIssuers 白名单校验("*" 跳过,全量迁移后改为具体值)
|
||
if (allowedIssuers.length === 1 && allowedIssuers[0] === '*') {
|
||
// 迁移模式:跳过白名单校验,仅验签名+过期
|
||
} else if (!iss || !allowedIssuers.includes(iss)) {
|
||
if (pathname.startsWith('/api/')) {
|
||
return NextResponse.json({ error: '无效的签发来源' }, { status: 401 })
|
||
}
|
||
return noCache(NextResponse.redirect(new URL('/login', request.nextUrl.origin)))
|
||
}
|
||
|
||
// 5. 验签(使用提取出的 iss 进行自引用校验)
|
||
// 迁移模式(allowedIssuers = ["*"]):跳过 iss 校验,仅验签名+过期
|
||
// 全量迁移后(allowedIssuers 为具体白名单):强制 iss 校验
|
||
const jwtSecret = cfg.jwtSecret
|
||
const isMigrationMode = allowedIssuers.length === 1 && allowedIssuers[0] === '*'
|
||
const expectedIss = isMigrationMode ? (iss || '*') : (iss || '')
|
||
const verified = await verifyJwtEdge(token, jwtSecret, expectedIss)
|
||
|
||
if (!verified) {
|
||
if (pathname.startsWith('/api/')) {
|
||
return NextResponse.json({ error: '登录已过期' }, { status: 401 })
|
||
}
|
||
const loginUrl = new URL('/login', request.nextUrl.origin)
|
||
const response = NextResponse.redirect(loginUrl)
|
||
response.cookies.delete('tlyq_session')
|
||
return response
|
||
}
|
||
|
||
// 6. 放行(设置 session cookie 供客户端展示用,httpOnly:false,仅含 username)
|
||
const response = noCache(NextResponse.next())
|
||
response.cookies.set('session', JSON.stringify({ username: verified.username }), {
|
||
httpOnly: false,
|
||
secure: process.env.NODE_ENV === 'production',
|
||
sameSite: 'lax' as const,
|
||
path: '/',
|
||
})
|
||
return response
|
||
}
|
||
}
|