From f57cdb5ec1e8697006e2d23d0b857c9a134e58cb Mon Sep 17 00:00:00 2001 From: shinya Date: Mon, 30 Jun 2025 20:58:55 +0800 Subject: [PATCH] feat: use middleware to auth --- src/app/api/login/route.ts | 35 +++++++++++++++++++++++ src/app/layout.tsx | 3 +- src/app/login/page.tsx | 5 ---- src/components/AuthProvider.tsx | 46 ------------------------------ src/middleware.ts | 50 +++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 53 deletions(-) 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 3b5d174..3974847 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -24,6 +24,41 @@ export async function POST(req: NextRequest) { ); } + // 登录成功:写入 HttpOnly Cookie + const res = NextResponse.json({ ok: true }); + res.cookies.set({ + name: 'password', + value: password, + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 30, // 30 天 + path: '/', + }); + + return res; + } catch (error) { + return NextResponse.json({ error: '服务器错误' }, { status: 500 }); + } +} + +// 使用 Cookie 校验登录状态 +export async function GET(req: NextRequest) { + try { + const result = process.env.PASSWORD; + + // 未设置 PASSWORD 则直接放行 + if (!result) { + return NextResponse.json({ ok: true }); + } + + const cookiePassword = req.cookies.get('password')?.value; + const matched = cookiePassword === result; + + if (!matched) { + return NextResponse.json({ ok: false }, { status: 401 }); + } + return NextResponse.json({ ok: true }); } catch (error) { return NextResponse.json({ error: '服务器错误' }, { status: 500 }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ef82afc..04f7d3b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,7 +3,6 @@ import { Inter } from 'next/font/google'; import './globals.css'; -import AuthProvider from '../components/AuthProvider'; import { ThemeProvider } from '../components/ThemeProvider'; const inter = Inter({ subsets: ['latin'] }); @@ -38,7 +37,7 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - {children} + {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 75f2fc9..5221c57 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -27,11 +27,6 @@ function LoginPageClient() { }); if (res.ok) { - // 保存密码以供后续请求使用 - if (typeof window !== 'undefined') { - localStorage.setItem('password', password); - } - const redirect = searchParams.get('redirect') || '/'; router.replace(redirect); } else if (res.status === 401) { diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx deleted file mode 100644 index 590ac9b..0000000 --- a/src/components/AuthProvider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import { usePathname, useRouter } from 'next/navigation'; -import { useEffect } from 'react'; - -interface Props { - children: React.ReactNode; -} - -export default function AuthProvider({ children }: Props) { - const router = useRouter(); - const pathname = usePathname(); - - useEffect(() => { - // 登录页或 API 路径不做校验,避免死循环 - if (pathname.startsWith('/login')) return; - - const password = localStorage.getItem('password'); - const fullPath = - typeof window !== 'undefined' - ? window.location.pathname + window.location.search - : pathname; - - // 有密码时验证 - (async () => { - try { - const res = await fetch('/api/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password }), - }); - - if (!res.ok) { - // 校验未通过,清理并跳转登录 - localStorage.removeItem('password'); - router.replace(`/login?redirect=${encodeURIComponent(fullPath)}`); - } - } catch (error) { - // 网络错误等也认为未登录 - router.replace(`/login?redirect=${encodeURIComponent(fullPath)}`); - } - })(); - }, [pathname, router]); - - return <>{children}; -} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..cef28f6 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// 全站(含 /api)鉴权中间件,运行于 Edge Runtime。 +export async function middleware(req: NextRequest) { + const { pathname, search } = req.nextUrl; + + // 1. 放行无需鉴权的路径 + if ( + pathname.startsWith('/login') || // 登录页 + pathname.startsWith('/api/login') || // 登录接口 + pathname.startsWith('/_next') || // Next.js 静态文件 + pathname === '/favicon.ico' || + pathname.startsWith('/icons') || + pathname === '/manifest.json' || + pathname === '/logo.png' || + pathname === '/screenshot.png' + ) { + return NextResponse.next(); + } + + // 通过后端接口验证登录状态(GET /api/login) + const origin = req.nextUrl.origin; + const verifyRes = await fetch(`${origin}/api/login`, { + method: 'GET', + headers: { + Cookie: req.headers.get('cookie') || '', + }, + }); + + if (verifyRes.ok) { + return NextResponse.next(); + } + + // 未通过校验:API 返回 401,页面跳转登录 + if (pathname.startsWith('/api')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const loginUrl = req.nextUrl.clone(); + loginUrl.pathname = '/login'; + loginUrl.searchParams.set('redirect', pathname + search); + return NextResponse.redirect(loginUrl); +} + +// 2. 指定哪些路径使用 middleware +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|manifest.json|icons|logo.png|screenshot.png).*)', + ], +};