feat: 去除basic auth认证,添加登录和登出 API,更新中间件以支持 cookie 验证

This commit is contained in:
feie9454 2025-11-23 10:13:08 +08:00
parent fd68c8a452
commit 4b24940f7e
6 changed files with 479 additions and 200 deletions

View File

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { config } from '@/lib/config'
import { createToken, AUTH_COOKIE_NAME } from '@/lib/auth'
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { username, password } = body
if (!username || !password) {
return NextResponse.json(
{ error: '用户名和密码不能为空' },
{ status: 400 }
)
}
if (username === config.auth.username && password === config.auth.password) {
const token = await createToken(username)
const response = NextResponse.json({ success: true })
response.cookies.set(AUTH_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 1 day
path: '/'
})
return response
} else {
return NextResponse.json(
{ error: '用户名或密码错误' },
{ status: 401 }
)
}
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ error: '服务器内部错误' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.delete('auth_token');
return response;
}

147
app/login/page.tsx Normal file
View File

@ -0,0 +1,147 @@
"use client";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Lock, User, ArrowRight, AlertCircle, Loader2 } from 'lucide-react';
export default function LoginPage() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok) {
router.push('/');
router.refresh();
} else {
setError(data.error || '登录失败');
}
} catch (err) {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4 sm:px-6 lg:px-8 transition-colors duration-200">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<div className="mx-auto h-16 w-16 bg-blue-600 rounded-2xl flex items-center justify-center shadow-lg transform rotate-3 hover:rotate-0 transition-transform duration-300">
<Lock className="h-8 w-8 text-white" />
</div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
</p>
</div>
<div className="bg-white dark:bg-gray-800 py-8 px-4 shadow-xl rounded-2xl sm:px-10 border border-gray-100 dark:border-gray-700">
<form className="space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800 animate-in fade-in slide-in-from-top-2">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
{error}
</h3>
</div>
</div>
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-colors"
placeholder="请输入用户名"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-colors"
placeholder="请输入密码"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center items-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 transform active:scale-[0.98]"
>
{loading ? (
<>
<Loader2 className="animate-spin -ml-1 mr-2 h-4 w-4" />
...
</>
) : (
<>
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</button>
</div>
</form>
</div>
<p className="text-center text-xs text-gray-500 dark:text-gray-400">
&copy; {new Date().getFullYear()} WinUpdate Neo. All rights reserved.
</p>
</div>
</div>
);
}

View File

@ -2,7 +2,16 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { XCircle } from 'lucide-react';
import {
Monitor,
Clock,
LogOut,
Upload,
Calendar,
Search,
Server,
AlertCircle
} from 'lucide-react';
import { format, differenceInMinutes } from 'date-fns';
interface Host {
@ -15,6 +24,7 @@ export default function Home() {
const [hosts, setHosts] = useState<Host[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const fetchHosts = async () => {
try {
@ -30,6 +40,16 @@ export default function Home() {
}
};
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
// 刷新页面以触发重新认证Basic Auth 或重定向)
window.location.reload();
} catch (error) {
console.error('Logout failed', error);
}
};
const formatDate = (date: string) => {
return format(new Date(date), 'yyyy-MM-dd HH:mm:ss');
};
@ -47,86 +67,174 @@ export default function Home() {
fetchHosts();
}, []);
const filteredHosts = hosts.filter(host =>
host.hostname.toLowerCase().includes(searchTerm.toLowerCase())
);
const activeHostsCount = hosts.filter(h => isRecent(h.lastUpdate)).length;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-semibold text-gray-900 dark:text-white"></h1>
<div className="space-x-4">
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
{/* Navigation Bar */}
<nav className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Monitor className="h-8 w-8 text-blue-600 dark:text-blue-400" />
<span className="ml-3 text-xl font-bold text-gray-900 dark:text-white">
</span>
</div>
<div className="flex items-center space-x-4">
<button
onClick={() => router.push('/tasks')}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
title="定时任务管理"
>
<Calendar className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline"></span>
</button>
<button
onClick={() => router.push('/upload')}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm"
title="上传新版本客户端"
>
<Upload className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline"></span>
</button>
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600 mx-2"></div>
<button
onClick={handleLogout}
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
title="退出登录"
>
<LogOut className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">退</span>
</button>
</div>
</div>
</div>
</nav>
{/* 主机列表卡片网格 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{hosts.map((host) => (
<div
key={host.hostname}
className="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden hover:shadow-md dark:hover:shadow-lg transition-shadow cursor-pointer"
onClick={() => navigateToHost(host.hostname)}
>
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center justify-between">
<main className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
{/* Stats & Search */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center space-x-4">
<div className="bg-white dark:bg-gray-800 px-4 py-3 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex items-center">
<div className="p-2 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 mr-3">
<Server className="h-5 w-5" />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
{decodeURI(host.hostname)}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase"></p>
<p className="text-xl font-bold text-gray-900 dark:text-white">{hosts.length}</p>
</div>
</div>
<div className="bg-white dark:bg-gray-800 px-4 py-3 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex items-center">
<div className="p-2 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 mr-3">
<div className="h-2.5 w-2.5 rounded-full bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30"></div>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase">线</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">{activeHostsCount}</p>
</div>
</div>
</div>
<div className="relative max-w-md w-full sm:w-64">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md leading-5 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-colors"
placeholder="搜索主机名..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Content */}
{loading ? (
<div className="flex flex-col justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-blue-400"></div>
<p className="mt-4 text-gray-500 dark:text-gray-400">...</p>
</div>
) : error ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 flex items-center justify-center">
<div className="text-center">
<AlertCircle className="mx-auto h-12 w-12 text-red-400 dark:text-red-500 mb-4" />
<h3 className="text-lg font-medium text-red-800 dark:text-red-200"></h3>
<p className="mt-2 text-red-700 dark:text-red-300">{error}</p>
<button
onClick={fetchHosts}
className="mt-4 px-4 py-2 bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100 rounded-md hover:bg-red-200 dark:hover:bg-red-700 transition-colors"
>
</button>
</div>
</div>
) : filteredHosts.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<Server className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white"></h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
: {formatDate(host.lastUpdate)}
{searchTerm ? '尝试调整搜索关键词' : '暂无主机连接记录'}
</p>
</div>
<div className="flex items-center">
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredHosts.map((host) => {
const online = isRecent(host.lastUpdate);
return (
<div
className={`h-3 w-3 rounded-full ${
isRecent(host.lastUpdate) ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
></div>
key={host.hostname}
onClick={() => navigateToHost(host.hostname)}
className="group bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md hover:border-blue-300 dark:hover:border-blue-700 transition-all duration-200 cursor-pointer relative"
>
<div className={`absolute top-0 left-0 w-1 h-full ${online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`}></div>
<div className="p-5 pl-6">
<div className="flex justify-between items-start mb-4">
<div className="bg-gray-100 dark:bg-gray-700 p-2 rounded-lg group-hover:bg-blue-50 dark:group-hover:bg-blue-900/20 transition-colors">
<Monitor className="h-6 w-6 text-gray-600 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400" />
</div>
</div>
</div>
</div>
))}
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
online
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}>
{online ? '在线' : '离线'}
</span>
</div>
{/* 加载状态 */}
{loading && (
<div className="flex justify-center items-center mt-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white"></div>
</div>
)}
{/* 错误提示 */}
{error && (
<div className="mt-8 bg-red-50 dark:bg-red-900/50 p-4 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<XCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
<h3 className="text-lg font-bold text-gray-900 dark:text-white truncate mb-1" title={decodeURI(host.hostname)}>
{decodeURI(host.hostname)}
</h3>
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
<p>{error}</p>
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400 mt-3">
<Clock className="h-4 w-4 mr-1.5 flex-shrink-0" />
<span className="truncate">
{formatDate(host.lastUpdate)}
</span>
</div>
</div>
</div>
</div>
)}
<div className="bg-gray-50 dark:bg-gray-700/30 px-5 py-3 border-t border-gray-100 dark:border-gray-700 flex justify-between items-center">
<span className="text-xs font-medium text-blue-600 dark:text-blue-400 group-hover:underline">
</span>
<div className="h-6 w-6 rounded-full bg-white dark:bg-gray-800 flex items-center justify-center shadow-sm text-gray-400 group-hover:text-blue-500 transition-colors">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
);
})}
</div>
)}
</main>
</div>
);
}

83
lib/auth.ts Normal file
View File

@ -0,0 +1,83 @@
import { config } from './config'
export const AUTH_COOKIE_NAME = 'auth_token'
const SECRET_KEY = config.auth.secret
// Helper to generate a random nonce
function generateNonce() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
}
// Helper to create a signed token (simplified JWT-like structure)
export async function createToken(username: string) {
const header = { alg: 'HS256', typ: 'JWT' }
const payload = {
sub: username,
iat: Date.now(),
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
nonce: generateNonce() // Ensure token is different every time
}
const encodedHeader = btoa(JSON.stringify(header))
const encodedPayload = btoa(JSON.stringify(payload))
const data = `${encodedHeader}.${encodedPayload}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(SECRET_KEY),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signature = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(data)
)
// Convert signature to base64
const signatureArray = Array.from(new Uint8Array(signature))
const encodedSignature = btoa(String.fromCharCode.apply(null, signatureArray))
return `${data}.${encodedSignature}`
}
// Helper to verify token
export async function verifyToken(token: string) {
try {
const parts = token.split('.')
if (parts.length !== 3) return false
const [encodedHeader, encodedPayload, encodedSignature] = parts
const data = `${encodedHeader}.${encodedPayload}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(SECRET_KEY),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
)
const signature = new Uint8Array(
atob(encodedSignature).split('').map(c => c.charCodeAt(0))
)
const isValid = await crypto.subtle.verify(
'HMAC',
key,
signature,
new TextEncoder().encode(data)
)
if (!isValid) return false
const payload = JSON.parse(atob(encodedPayload))
if (payload.exp < Date.now()) return false
return true
} catch (e) {
return false
}
}

View File

@ -1,94 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { config as appConfig } from './lib/config'
const AUTH_COOKIE_NAME = 'auth_token'
const SECRET_KEY = appConfig.auth.secret
// Helper to generate a random nonce
function generateNonce() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
}
// Helper to create a signed token (simplified JWT-like structure)
async function createToken(username: string) {
const header = { alg: 'HS256', typ: 'JWT' }
const payload = {
sub: username,
iat: Date.now(),
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
nonce: generateNonce() // Ensure token is different every time
}
const encodedHeader = btoa(JSON.stringify(header))
const encodedPayload = btoa(JSON.stringify(payload))
const data = `${encodedHeader}.${encodedPayload}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(appConfig.auth.secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signature = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(data)
)
// Convert signature to base64
const signatureArray = Array.from(new Uint8Array(signature))
const encodedSignature = btoa(String.fromCharCode.apply(null, signatureArray))
return `${data}.${encodedSignature}`
}
// Helper to verify token
async function verifyToken(token: string) {
try {
const parts = token.split('.')
if (parts.length !== 3) return false
const [encodedHeader, encodedPayload, encodedSignature] = parts
const data = `${encodedHeader}.${encodedPayload}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(SECRET_KEY),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
)
const signature = new Uint8Array(
atob(encodedSignature).split('').map(c => c.charCodeAt(0))
)
const isValid = await crypto.subtle.verify(
'HMAC',
key,
signature,
new TextEncoder().encode(data)
)
if (!isValid) return false
const payload = JSON.parse(atob(encodedPayload))
if (payload.exp < Date.now()) return false
return true
} catch (e) {
return false
}
}
import { verifyToken, AUTH_COOKIE_NAME } from './lib/auth'
export async function middleware(req: NextRequest) {
const url = new URL(req.url)
// Skip auth for certain paths (same logic as original Express app)
// Skip auth for certain paths
if (
url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/api/auth/login') || // Allow login API
url.pathname.startsWith('/api/') || // Other APIs might need their own auth or be public
url.pathname.startsWith('/screenshots/') ||
url.pathname.startsWith('/downloads/') ||
url.pathname.startsWith('/manifest.json') ||
@ -96,70 +15,41 @@ export async function middleware(req: NextRequest) {
url.pathname.includes('favicon.png') ||
url.pathname.includes('install') ||
url.pathname.includes('WinupdateCore') ||
req.method === 'POST' ||
url.pathname.startsWith('/_next/') || // Next.js static files
url.pathname.startsWith('/api-test') // API test page (for development)
// req.method === 'POST' || // Remove this blanket allowance for POST
url.pathname.startsWith('/_next/') ||
url.pathname.startsWith('/api-test')
) {
return NextResponse.next()
}
// 1. Try Cookie Authentication
const authCookie = req.cookies.get(AUTH_COOKIE_NAME)
const isLoginPage = url.pathname === '/login'
let isValidToken = false
if (authCookie) {
const isValid = await verifyToken(authCookie.value)
if (isValid) {
isValidToken = await verifyToken(authCookie.value)
}
// Handle Login Page
if (isLoginPage) {
if (isValidToken) {
// If already logged in, redirect to home
return NextResponse.redirect(new URL('/', req.url))
}
// Allow access to login page
return NextResponse.next()
}
// Handle Protected Routes
if (!isValidToken) {
// Redirect to login page if not authenticated
const loginUrl = new URL('/login', req.url)
// Optional: Add return URL support
// loginUrl.searchParams.set('from', url.pathname)
return NextResponse.redirect(loginUrl)
}
// 2. Fallback to Basic Auth
const authHeader = req.headers.get('authorization')
if (!authHeader) {
return new NextResponse('Authentication required', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Restricted Access"'
}
})
}
try {
// Use atob for Edge compatibility
const authValue = authHeader.split(' ')[1]
const auth = atob(authValue)
const [username, password] = auth.split(':')
if (username === appConfig.auth.username && password === appConfig.auth.password) {
const response = NextResponse.next()
// Set auth cookie on successful Basic Auth
const token = await createToken(username)
response.cookies.set(AUTH_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 1 day
path: '/'
})
return response
} else {
return new NextResponse('Authentication failed', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Restricted Access"'
}
})
}
} catch {
return new NextResponse('Invalid authentication', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Restricted Access"'
}
})
}
return NextResponse.next()
}
// 配置中间件匹配的路径