From 10b8f80aeab4d1678341e2bcade0f188485ac9bf Mon Sep 17 00:00:00 2001 From: shinya Date: Wed, 9 Jul 2025 13:47:23 +0800 Subject: [PATCH] feat: use middleware to auth --- src/app/api/login/route.ts | 102 +++++++++++++++++++-- src/app/api/logout/route.ts | 16 ++++ src/app/api/register/route.ts | 62 ++++++++++++- src/app/layout.tsx | 9 +- src/app/login/page.tsx | 12 ++- src/app/page.tsx | 23 +++-- src/components/AuthProvider.tsx | 108 ---------------------- src/components/LogoutButton.tsx | 41 +++++---- src/middleware.ts | 155 ++++++++++++++++++++++++++++++++ 9 files changed, 379 insertions(+), 149 deletions(-) create mode 100644 src/app/api/logout/route.ts delete mode 100644 src/components/AuthProvider.tsx create mode 100644 src/middleware.ts diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index 50b6309..f47208f 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -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 { + 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 { + 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 }); diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts new file mode 100644 index 0000000..fce7830 --- /dev/null +++ b/src/app/api/logout/route.ts @@ -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; +} diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index 3b69bc1..0e37d2d 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -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 { + 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 { + 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 }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e41baed..014f087 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 { } 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 > - {children} + {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 346e682..4fc0b3c 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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 ( - + Loading...}> ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 390d16d..acb19db 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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([]); const [hotTvShows, setHotTvShows] = useState([]); 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')} /> diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx deleted file mode 100644 index dd67eb1..0000000 --- a/src/components/AuthProvider.tsx +++ /dev/null @@ -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(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 ( -
-
- -
-
- {/* 动画认证图标 */} -
-
-
🔐
- {/* 旋转光环 */} -
-
- - {/* 浮动粒子效果 */} -
-
-
-
-
-
- - {/* 品牌标题 */} -

- {siteName} -

- - {/* 加载消息 */} -
-

- 正在验证您的身份... -

-

- 请稍候,马上就好 -

-
-
-
- ); - } else { - return <>{children}; - } -} diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx index 3814b1b..41c809c 100644 --- a/src/components/LogoutButton.tsx +++ b/src/components/LogoutButton.tsx @@ -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() { ); -} +}; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..3c300fb --- /dev/null +++ b/src/middleware.ts @@ -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 { + 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).*)', + ], +};