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>