Files
MoonTVPlus/src/middleware.ts
2026-01-27 09:08:44 +08:00

166 lines
4.8 KiB
TypeScript
Raw 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.
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { TOKEN_CONFIG } from '@/lib/refresh-token';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 跳过不需要认证的路径
if (shouldSkipAuth(pathname)) {
return NextResponse.next();
}
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (!process.env.PASSWORD) {
// 如果未配置密码,重定向到警告页面
const warningUrl = new URL('/warning', request.url);
return warningUrl.pathname === pathname ? NextResponse.next() : NextResponse.redirect(warningUrl);
}
// 从cookie获取认证信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo) {
return handleAuthFailure(request, pathname);
}
// localstorage模式在middleware中完成验证
if (storageType === 'localstorage') {
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
return handleAuthFailure(request, pathname);
}
return NextResponse.next();
}
// 其他模式:验证签名和时间戳,支持自动续期
// 检查是否有用户名非localStorage模式下密码不存储在cookie中
if (!authInfo.username || !authInfo.role || !authInfo.signature || !authInfo.timestamp) {
return handleAuthFailure(request, pathname);
}
// 强制要求新版 Cookie必须包含 tokenId 和 refreshToken
if (!authInfo.tokenId || !authInfo.refreshToken || !authInfo.refreshExpires) {
console.log(`Old cookie format detected for ${authInfo.username}, forcing re-login`);
return handleAuthFailure(request, pathname);
}
// 验证 Access Token 时间戳
const ACCESS_TOKEN_AGE = TOKEN_CONFIG.ACCESS_TOKEN_AGE;
const now = Date.now();
const age = now - authInfo.timestamp;
// Access Token 已过期,前端负责刷新
if (age > ACCESS_TOKEN_AGE) {
console.log(`Access token expired for ${authInfo.username}, redirecting to login`);
return handleAuthFailure(request, pathname);
}
// Access Token 未过期,验证签名
const isValidSignature = await verifySignature(
authInfo.username,
authInfo.role,
authInfo.timestamp,
authInfo.signature,
process.env.PASSWORD || ''
);
if (!isValidSignature) {
return handleAuthFailure(request, pathname);
}
// 签名验证通过
// 注意Token 续期由前端负责Middleware 不再自动刷新
return NextResponse.next();
}
// 验证签名
async function verifySignature(
username: string,
role: string,
timestamp: number,
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
// 构造与生成签名时相同的数据结构
const dataToSign = JSON.stringify({
username,
role,
timestamp
});
const messageData = encoder.encode(dataToSign);
try {
// 导入密钥
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
// 将十六进制字符串转换为Uint8Array
const signatureBuffer = new Uint8Array(
signature.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []
);
// 验证签名
return await crypto.subtle.verify(
'HMAC',
key,
signatureBuffer,
messageData
);
} catch (error) {
console.error('签名验证失败:', error);
return false;
}
}
// 处理认证失败的情况
function handleAuthFailure(
request: NextRequest,
pathname: string
): NextResponse {
// 如果是 API 路由,返回 401 状态码
if (pathname.startsWith('/api')) {
return new NextResponse('Unauthorized', { status: 401 });
}
// 否则重定向到登录页面
const loginUrl = new URL('/login', request.url);
// 保留完整的URL包括查询参数
const fullUrl = `${pathname}${request.nextUrl.search}`;
loginUrl.searchParams.set('redirect', fullUrl);
return NextResponse.redirect(loginUrl);
}
// 判断是否需要跳过认证的路径
function shouldSkipAuth(pathname: string): boolean {
const skipPaths = [
'/_next',
'/favicon.ico',
'/robots.txt',
'/manifest.json',
'/icons/',
'/logo.png',
'/screenshot.png',
];
return skipPaths.some((path) => pathname.startsWith(path));
}
// 配置middleware匹配规则
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/auth/refresh|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play|api/emby/sources).*)',
],
};