diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..7a21549 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -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 } + ) + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..119a8d7 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -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; +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..4fd582a --- /dev/null +++ b/app/login/page.tsx @@ -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(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 ( +
+
+
+
+ +
+

+ 系统登录 +

+

+ 请输入您的管理员凭证以继续 +

+
+ +
+
+ {error && ( +
+
+
+
+
+

+ {error} +

+
+
+
+ )} + +
+ +
+
+
+ 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="请输入用户名" + /> +
+
+ +
+ +
+
+
+ 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="请输入密码" + /> +
+
+ +
+ +
+
+
+ +

+ © {new Date().getFullYear()} WinUpdate Neo. All rights reserved. +

+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index ff29ecd..a1922bc 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 ( -
-
-
-
-

屏幕截图监控系统

-
+
+ {/* Navigation Bar */} + - {/* 主机列表卡片网格 */} -
- {hosts.map((host) => ( -
navigateToHost(host.hostname)} - > -
-
-
-

- {decodeURI(host.hostname)} -

-

- 最后更新: {formatDate(host.lastUpdate)} -

-
-
-
-
-
-
+
+ {/* Stats & Search */} +
+
+
+
+
- ))} +
+

总主机数

+

{hosts.length}

+
+
+
+
+
+
+
+

在线主机

+

{activeHostsCount}

+
+
- {/* 加载状态 */} - {loading && ( -
-
+
+
+
- )} + setSearchTerm(e.target.value)} + /> +
+
- {/* 错误提示 */} - {error && ( -
-
-
- -
-
-

- 加载失败 -

-
-

{error}

+ {/* Content */} + {loading ? ( +
+
+

正在加载主机列表...

+
+ ) : error ? ( +
+
+ +

加载失败

+

{error}

+ +
+
+ ) : filteredHosts.length === 0 ? ( +
+ +

没有找到主机

+

+ {searchTerm ? '尝试调整搜索关键词' : '暂无主机连接记录'} +

+
+ ) : ( +
+ {filteredHosts.map((host) => { + const online = isRecent(host.lastUpdate); + return ( +
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" + > +
+
+
+
+ +
+ + {online ? '在线' : '离线'} + +
+ +

+ {decodeURI(host.hostname)} +

+ +
+ + + {formatDate(host.lastUpdate)} + +
+
+ +
+ + 查看详情 + +
+ + + +
-
-
- )} -
-
+ ); + })} +
+ )} +
); } diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..89f8f1b --- /dev/null +++ b/lib/auth.ts @@ -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 + } +} diff --git a/middleware.ts b/middleware.ts index 267c843..47981a0 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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) { - return NextResponse.next() + 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() } - // 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"' - } - }) + // 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) } - 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() } // 配置中间件匹配的路径