// 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 | 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 | 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 } }