170 lines
6.1 KiB
TypeScript
170 lines
6.1 KiB
TypeScript
// shared/lib/auth/middleware.ts — 通用 middleware 工厂(Edge Runtime 兼容)
|
||
// 不使用 Node.js crypto,仅用 atob 解码 JWT payload
|
||
import { NextResponse, type NextRequest } from 'next/server'
|
||
|
||
export interface MiddlewareConfig {
|
||
/** 公开路径(不需要认证),默认 ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico'] */
|
||
publicPaths?: string[]
|
||
/** 需要 admin 权限的路径前缀,默认 ['/admin'] */
|
||
adminPaths?: string[]
|
||
/** 本地 cookie 名(如 'session_issue'),用于回退验证 */
|
||
localCookieName?: string
|
||
/** 启用 API Key 认证(读取 ALLOWED_API_KEYS 环境变量),默认 false */
|
||
enableApiKey?: boolean
|
||
/** 管理员角色名,默认 'admin' */
|
||
adminRole?: string
|
||
}
|
||
|
||
// Edge Runtime 兼容:用 atob 解码 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
|
||
}
|
||
|
||
/**
|
||
* 创建通用 middleware
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* // src/middleware.ts — 使用默认配置
|
||
* export { middleware, config } from '@shared/lib/auth/middleware'
|
||
* ```
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* // src/middleware.ts — 自定义配置
|
||
* import { createMiddleware } from '@shared/lib/auth/middleware'
|
||
* export const middleware = createMiddleware({
|
||
* localCookieName: 'session_issue',
|
||
* adminPaths: ['/settings'],
|
||
* enableApiKey: true,
|
||
* })
|
||
* export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }
|
||
* ```
|
||
*/
|
||
export function createMiddleware(cfg: MiddlewareConfig = {}) {
|
||
const publicPaths = cfg.publicPaths || ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico']
|
||
const adminPaths = cfg.adminPaths || ['/admin']
|
||
const localCookieName = cfg.localCookieName
|
||
const enableApiKey = cfg.enableApiKey || false
|
||
const adminRole = cfg.adminRole || 'admin'
|
||
|
||
return function middleware(request: NextRequest) {
|
||
const { pathname } = request.nextUrl
|
||
|
||
// 公开路径放行
|
||
if (publicPaths.some(p => pathname.startsWith(p))) {
|
||
// 登录页:已登录用户自动跳转首页
|
||
if (pathname === '/login') {
|
||
const token = request.cookies.get('tlyq_session')?.value
|
||
|| (localCookieName ? request.cookies.get(localCookieName)?.value : undefined)
|
||
const payload = token ? decodeJwtPayload(token) : null
|
||
if (isValidPayload(payload)) {
|
||
return NextResponse.redirect(new URL('/', request.url))
|
||
}
|
||
}
|
||
return NextResponse.next()
|
||
}
|
||
|
||
// 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 })
|
||
}
|
||
}
|
||
}
|
||
|
||
// 验证 tlyq_session(共享 JWT)
|
||
const sharedToken = request.cookies.get('tlyq_session')?.value
|
||
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
|
||
|
||
if (isValidPayload(sharedPayload)) {
|
||
// 管理路径检查 admin 权限
|
||
if (adminPaths.some(p => pathname.startsWith(p))) {
|
||
const role = sharedPayload?.role as string
|
||
if (role !== adminRole && role !== 'admin') {
|
||
return new NextResponse('Forbidden', { status: 403 })
|
||
}
|
||
}
|
||
|
||
// 设置 session cookie(兼容旧代码)
|
||
if (sharedPayload) {
|
||
const response = pathname.startsWith('/api/') ? NextResponse.next() : noCache(NextResponse.next())
|
||
response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), {
|
||
httpOnly: true, sameSite: 'lax', path: '/',
|
||
})
|
||
return response
|
||
}
|
||
}
|
||
|
||
// 回退本地 cookie(可选)
|
||
if (localCookieName) {
|
||
const localToken = request.cookies.get(localCookieName)?.value
|
||
const localPayload = localToken ? decodeJwtPayload(localToken) : null
|
||
|
||
if (isValidPayload(localPayload)) {
|
||
// 管理路径检查 admin 权限
|
||
if (adminPaths.some(p => pathname.startsWith(p))) {
|
||
const role = localPayload?.role as string
|
||
if (role !== adminRole && role !== 'admin') {
|
||
return new NextResponse('Forbidden', { status: 403 })
|
||
}
|
||
}
|
||
|
||
if (pathname.startsWith('/api/')) {
|
||
return NextResponse.next()
|
||
}
|
||
if (localPayload) {
|
||
const response = noCache(NextResponse.next())
|
||
response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
|
||
httpOnly: true, sameSite: 'lax', path: '/',
|
||
})
|
||
return response
|
||
}
|
||
}
|
||
|
||
// 清除无效的本地 cookie
|
||
if (localToken) {
|
||
const loginUrl = new URL('/login', request.url)
|
||
const response = NextResponse.redirect(loginUrl)
|
||
response.cookies.delete(localCookieName)
|
||
if (sharedToken) response.cookies.delete('tlyq_session')
|
||
return response
|
||
}
|
||
}
|
||
|
||
// 未认证:API 返回 401,页面重定向登录
|
||
if (pathname.startsWith('/api/')) {
|
||
return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||
}
|
||
return noCache(NextResponse.redirect(new URL('/login', request.url)))
|
||
}
|
||
}
|
||
|
||
// 默认导出(无配置,适用于简单站点)
|
||
export const middleware = createMiddleware()
|
||
|
||
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }
|