feat: use middleware to auth

This commit is contained in:
shinya
2025-07-09 13:47:23 +08:00
parent e20dc8f4df
commit 10b8f80aea
9 changed files with 379 additions and 149 deletions

View File

@@ -1,4 +1,4 @@
/* eslint-disable no-console */
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
@@ -11,6 +11,51 @@ const STORAGE_TYPE =
(process.env.NEXT_PUBLIC_STORAGE_TYPE as string | undefined) ||
'localstorage';
// 生成签名
async function generateSignature(
data: string,
secret: string
): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
// 导入密钥
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
// 生成签名
const signature = await crypto.subtle.sign('HMAC', key, messageData);
// 转换为十六进制字符串
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// 生成认证Cookie带签名
async function generateAuthCookie(
username?: string,
password?: string
): Promise<string> {
const authData: any = { password };
if (username && process.env.PASSWORD) {
authData.username = username;
// 使用密码作为密钥对用户名进行签名
const signature = await generateSignature(username, process.env.PASSWORD);
authData.signature = signature;
authData.timestamp = Date.now(); // 添加时间戳防重放攻击
}
return encodeURIComponent(JSON.stringify(authData));
}
export async function POST(req: NextRequest) {
try {
// 本地 / localStorage 模式——仅校验固定密码
@@ -18,7 +63,18 @@ export async function POST(req: NextRequest) {
const envPassword = process.env.PASSWORD;
// 未配置 PASSWORD 时直接放行
if (!envPassword) return NextResponse.json({ ok: true });
if (!envPassword) {
const response = NextResponse.json({ ok: true });
// 清除可能存在的认证cookie
response.cookies.set('auth', '', {
path: '/',
expires: new Date(0),
sameSite: 'strict',
});
return response;
}
const { password } = await req.json();
if (typeof password !== 'string') {
@@ -32,7 +88,19 @@ export async function POST(req: NextRequest) {
);
}
return NextResponse.json({ ok: true });
// 验证成功设置认证cookie
const response = NextResponse.json({ ok: true });
const cookieValue = await generateAuthCookie(undefined, password);
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'strict',
});
return response;
}
// 数据库 / redis 模式——校验用户名并尝试连接数据库
@@ -50,7 +118,19 @@ export async function POST(req: NextRequest) {
username === process.env.USERNAME &&
password === process.env.PASSWORD
) {
return NextResponse.json({ ok: true });
// 验证成功设置认证cookie
const response = NextResponse.json({ ok: true });
const cookieValue = await generateAuthCookie(username, password);
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'strict',
});
return response;
} else if (username === process.env.USERNAME) {
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 });
}
@@ -71,7 +151,19 @@ export async function POST(req: NextRequest) {
);
}
return NextResponse.json({ ok: true });
// 验证成功设置认证cookie
const response = NextResponse.json({ ok: true });
const cookieValue = await generateAuthCookie(username, password);
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'strict',
});
return response;
} catch (err) {
console.error('数据库验证失败', err);
return NextResponse.json({ error: '数据库错误' }, { status: 500 });

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
export const runtime = 'edge';
export async function POST() {
const response = NextResponse.json({ ok: true });
// 清除认证cookie
response.cookies.set('auth', '', {
path: '/',
expires: new Date(0),
sameSite: 'strict',
});
return response;
}

View File

@@ -1,4 +1,4 @@
/* eslint-disable no-console */
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
@@ -11,6 +11,52 @@ const STORAGE_TYPE =
(process.env.NEXT_PUBLIC_STORAGE_TYPE as string | undefined) ||
'localstorage';
// 生成签名
async function generateSignature(
data: string,
secret: string
): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
// 导入密钥
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
// 生成签名
const signature = await crypto.subtle.sign('HMAC', key, messageData);
// 转换为十六进制字符串
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// 生成认证Cookie带签名
async function generateAuthCookie(
username: string,
password: string
): Promise<string> {
const authData: any = {
password,
username,
timestamp: Date.now(),
};
// 使用process.env.PASSWORD作为签名密钥而不是用户密码
const signingKey = process.env.PASSWORD || '';
const signature = await generateSignature(username, signingKey);
authData.signature = signature;
return encodeURIComponent(JSON.stringify(authData));
}
export async function POST(req: NextRequest) {
try {
// localstorage 模式下不支持注册
@@ -57,7 +103,19 @@ export async function POST(req: NextRequest) {
});
await db.saveAdminConfig(config);
return NextResponse.json({ ok: true });
// 注册成功设置认证cookie
const response = NextResponse.json({ ok: true });
const cookieValue = await generateAuthCookie(username, password);
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'strict',
});
return response;
} catch (err) {
console.error('数据库注册失败', err);
return NextResponse.json({ error: '数据库错误' }, { status: 500 });

View File

@@ -7,7 +7,6 @@ import 'sweetalert2/dist/sweetalert2.min.css';
import { getConfig } from '@/lib/config';
import AuthProvider from '../components/AuthProvider';
import { SiteProvider } from '../components/SiteProvider';
import { ThemeProvider } from '../components/ThemeProvider';
@@ -25,11 +24,7 @@ export async function generateMetadata(): Promise<Metadata> {
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: 'cover',
themeColor: '#000000',
};
export default function RootLayout({
@@ -69,7 +64,7 @@ export default function RootLayout({
disableTransitionOnChange
>
<SiteProvider siteName={siteName} announcement={announcement}>
<AuthProvider>{children}</AuthProvider>
{children}
</SiteProvider>
</ThemeProvider>
</body>

View File

@@ -46,7 +46,7 @@ function LoginPageClient() {
});
if (res.ok) {
// 保存密码和用户名以供后续请求使用
// API 已经设置了认证cookie这里只保存到localStorage用于向后兼容
if (typeof window !== 'undefined') {
localStorage.setItem('password', password);
if (shouldAskUsername) {
@@ -62,12 +62,14 @@ function LoginPageClient() {
const data = await res.json().catch(() => ({}));
setError(data.error ?? '服务器错误');
}
} catch (error) {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
// 新增:处理注册逻辑
// 处理注册逻辑
const handleRegister = async () => {
setError(null);
if (!password || !username) return;
@@ -81,16 +83,20 @@ function LoginPageClient() {
});
if (res.ok) {
// API 已经设置了认证cookie这里只保存到localStorage用于向后兼容
if (typeof window !== 'undefined') {
localStorage.setItem('password', password);
localStorage.setItem('username', username);
}
const redirect = searchParams.get('redirect') || '/';
router.replace(redirect);
} else {
const data = await res.json().catch(() => ({}));
setError(data.error ?? '服务器错误');
}
} catch (error) {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
@@ -182,7 +188,7 @@ function LoginPageClient() {
export default function LoginPage() {
return (
<Suspense>
<Suspense fallback={<div>Loading...</div>}>
<LoginPageClient />
</Suspense>
);

View File

@@ -20,20 +20,25 @@ import { useSite } from '@/components/SiteProvider';
import VideoCard from '@/components/VideoCard';
function HomeClient() {
const [activeTab, setActiveTab] = useState('home');
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
const [loading, setLoading] = useState(true);
const { announcement } = useSite();
const [showAnnouncement, setShowAnnouncement] = useState(() => {
// 检查本地存储中是否已记录弹窗显示状态
const hasSeenAnnouncement = localStorage.getItem('hasSeenAnnouncement');
if (hasSeenAnnouncement !== announcement) {
return true;
const [showAnnouncement, setShowAnnouncement] = useState(false);
// 检查公告弹窗状态
useEffect(() => {
if (typeof window !== 'undefined' && announcement) {
const hasSeenAnnouncement = localStorage.getItem('hasSeenAnnouncement');
if (hasSeenAnnouncement !== announcement) {
setShowAnnouncement(true);
} else {
setShowAnnouncement(Boolean(!hasSeenAnnouncement && announcement));
}
}
return !hasSeenAnnouncement && announcement; // 未记录且有公告时显示弹窗
});
}, [announcement]);
// 收藏夹数据
type FavoriteItem = {
@@ -129,7 +134,7 @@ function HomeClient() {
{ label: '收藏夹', value: 'favorites' },
]}
active={activeTab}
onChange={setActiveTab}
onChange={(value) => setActiveTab(value as 'home' | 'favorites')}
/>
</div>

View File

@@ -1,108 +0,0 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { ThemeToggle } from '@/components/ThemeToggle';
import { useSite } from './SiteProvider';
interface Props {
children: React.ReactNode;
}
export default function AuthProvider({ children }: Props) {
const router = useRouter();
const pathname = usePathname();
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const { siteName } = useSite();
const authenticate = useCallback(async () => {
// 登录页
if (pathname.startsWith('/login') || pathname.startsWith('/api')) {
setIsAuthenticated(true);
return;
}
// 从localStorage获取密码和用户名
const password = localStorage.getItem('password');
const username = localStorage.getItem('username');
const fullPath =
typeof window !== 'undefined'
? window.location.pathname + window.location.search
: pathname;
// 尝试认证
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password, username }),
});
if (!res.ok) throw new Error('认证失败');
setIsAuthenticated(true);
} catch (error) {
// 认证失败,清理并跳转登录
setIsAuthenticated(false);
localStorage.removeItem('password');
localStorage.removeItem('username');
router.replace(`/login?redirect=${encodeURIComponent(fullPath)}`);
}
}, [pathname, router]);
useEffect(() => {
authenticate();
}, [pathname, authenticate]);
// 认证状态未知时显示加载状态
if (!isAuthenticated) {
return (
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='absolute top-4 right-4'>
<ThemeToggle />
</div>
<div className='text-center max-w-md mx-auto px-6'>
{/* 动画认证图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>🔐</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 品牌标题 */}
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
{siteName}
</h1>
{/* 加载消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
...
</p>
<p className='text-sm text-gray-500 dark:text-gray-400'>
</p>
</div>
</div>
</div>
);
} else {
return <>{children}</>;
}
}

View File

@@ -1,23 +1,34 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-console */
'use client';
import { LogOut } from 'lucide-react';
import { useState } from 'react';
/**
* 退出登录按钮
*
* 功能:
* 1. 清除 localStorage 中保存的 username 和 password
* 2. 跳转到 /login 页面
*/
export function LogoutButton() {
const handleLogout = () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('username');
localStorage.removeItem('password');
export const LogoutButton: React.FC = () => {
const [loading, setLoading] = useState(false);
const handleLogout = async () => {
if (loading) return;
setLoading(true);
try {
// 调用注销API来清除cookie
await fetch('/api/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('注销请求失败:', error);
}
// 使用 replace避免用户返回上一页时仍然处于已登录状态
// 清除localStorage中的认证信息向后兼容
if (typeof window !== 'undefined') {
localStorage.removeItem('password');
localStorage.removeItem('username');
}
window.location.reload();
};
@@ -30,4 +41,4 @@ export function LogoutButton() {
<LogOut className='w-full h-full' />
</button>
);
}
};

155
src/middleware.ts Normal file
View File

@@ -0,0 +1,155 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
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 (storageType === 'localstorage' && !process.env.PASSWORD) {
return NextResponse.next();
}
// 从cookie获取认证信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo) {
return redirectToLogin(request, pathname);
}
// localstorage模式在middleware中完成验证
if (storageType === 'localstorage') {
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
return redirectToLogin(request, pathname);
}
return NextResponse.next();
}
// 其他模式:只验证签名
// 检查是否有用户名和密码
if (!authInfo.username || !authInfo.password) {
return redirectToLogin(request, pathname);
}
// 验证签名(如果存在)
if (authInfo.signature) {
const isValidSignature = await verifySignature(
authInfo.username,
authInfo.signature,
process.env.PASSWORD || ''
);
// 签名验证通过即可
if (isValidSignature) {
return NextResponse.next();
}
}
// 签名验证失败或不存在签名
return redirectToLogin(request, pathname);
}
// 验证签名
async function verifySignature(
data: string,
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
try {
// 导入密钥
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
// 将十六进制字符串转换为ArrayBuffer
const signatureBuffer = new Uint8Array(
signature.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []
).buffer;
// 验证签名
return await crypto.subtle.verify(
'HMAC',
key,
signatureBuffer,
messageData
);
} catch (error) {
console.error('签名验证失败:', error);
return false;
}
}
// 从cookie获取认证信息
function getAuthInfoFromCookie(request: NextRequest): {
password?: string;
username?: string;
signature?: string;
timestamp?: number;
} | null {
const authCookie = request.cookies.get('auth');
if (!authCookie) {
return null;
}
try {
const decoded = decodeURIComponent(authCookie.value);
const authData = JSON.parse(decoded);
return authData;
} catch (error) {
return null;
}
}
// 重定向到登录页面
function redirectToLogin(request: NextRequest, pathname: string): NextResponse {
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 = [
'/login',
'/api/login',
'/api/register',
'/api/logout',
'/_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).*)',
],
};