shared/lib/auth/middleware-v2.ts

126 lines
5.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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