shared/lib/auth/middleware.ts

153 lines
5.9 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.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 })
}
}
}
// 优先验证本地 cookie含 role由本站 OIDC callback 签发)
// 回退到 tlyq_session共享 JWT不含 role仅证明身份
const sharedToken = request.cookies.get('tlyq_session')?.value
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
const localToken = localCookieName ? request.cookies.get(localCookieName)?.value : undefined
const localPayload = localToken ? decodeJwtPayload(localToken) : null
// 1. 本地 cookie 优先(含 role可做 admin 检查)
if (isValidPayload(localPayload)) {
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()
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
}
// 2. 回退到 tlyq_session共享 JWT不含 role只验证身份
if (isValidPayload(sharedPayload)) {
// 注意tlyq_session 不含 roleadmin 检查在各站点 callback 中通过查本地 DB 完成
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
}
// 未认证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).*)'] }