diff --git a/api/auth.js b/api/auth.js index 0678fe64..c29f1d49 100644 --- a/api/auth.js +++ b/api/auth.js @@ -1,8 +1,110 @@ +// 简单的内存速率限制 +const rateLimit = new Map(); +const MAX_ATTEMPTS = 5; +const WINDOW_MS = 15 * 60 * 1000; // 15分钟 +const crypto = require('crypto'); + +// 生成安全的随机token +function generateSecureToken(length = 32) { + return crypto.randomBytes(length).toString('hex'); +} + +// 生成带盐的安全访问令牌 +function generateAccessToken(password) { + const salt = crypto.randomBytes(16).toString('hex'); + const timestamp = Date.now().toString(); + const payload = `${password}:${salt}:${timestamp}`; + const hash = crypto.createHash('sha256').update(payload).digest('hex'); + return `${salt}:${timestamp}:${hash}`; +} + +// 验证访问令牌 +function verifyAccessToken(token, password) { + if (!token || !password) return false; + + const parts = token.split(':'); + if (parts.length !== 3) return false; + + const [salt, timestamp, hash] = parts; + + // 检查令牌时效性(7天) + const tokenAge = Date.now() - parseInt(timestamp); + if (tokenAge > 7 * 24 * 60 * 60 * 1000) { + return false; + } + + // 验证哈希 + const expectedHash = crypto.createHash('sha256').update(`${password}:${salt}:${timestamp}`).digest('hex'); + return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expectedHash, 'hex')); +} + +// 检查速率限制 +function checkRateLimit(ip) { + const now = Date.now(); + + if (!rateLimit.has(ip)) { + rateLimit.set(ip, { count: 0, resetTime: now + WINDOW_MS }); + } + + const clientData = rateLimit.get(ip); + if (now > clientData.resetTime) { + clientData.count = 0; + clientData.resetTime = now + WINDOW_MS; + } + + if (clientData.count >= MAX_ATTEMPTS) { + return false; + } + + clientData.count++; + return true; +} + +// 配置CORS +function setCORSHeaders(req, res) { + const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [ + 'http://localhost:3000', + 'http://localhost:8080', + 'http://localhost:5173' + ]; + const origin = req.headers.origin; + + if (allowedOrigins.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + } else { + // 不允许未知源,返回403 + return res.status(403).set('Content-Type', 'text/plain').end('Forbidden'); + } + + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CSRF-Token'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); +} + +// 设置安全的cookie +function setSecureCookie(req, name, value, maxAge) { + const isSecure = process.env.NODE_ENV === 'production' || + process.env.VERCEL_ENV === 'production' || + req.headers['x-forwarded-proto'] === 'https'; + + const cookieOptions = [ + `${name}=${value}`, + 'HttpOnly', + 'Path=/', + `Max-Age=${maxAge}`, + 'SameSite=Lax' + ]; + + if (isSecure) { + cookieOptions.push('Secure'); + } + + return cookieOptions.join('; '); +} + export default function handler(req, res) { // 设置CORS头 - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + setCORSHeaders(req, res); if (req.method === 'OPTIONS') { res.status(200).end(); @@ -19,20 +121,45 @@ export default function handler(req, res) { }); } + // 获取客户端IP + const clientIP = req.headers['x-forwarded-for'] || req.connection.remoteAddress; + if (req.method === 'POST') { - const { password, action } = req.body; + const { password, action, csrfToken } = req.body; if (action === 'verify') { + // 检查速率限制 + if (!checkRateLimit(clientIP)) { + return res.status(429).json({ + success: false, + message: 'Too many attempts. Please try again later.' + }); + } + + // 验证CSRF token(从Cookie或Header获取) + const storedCSRFToken = req.cookies?.csrf_token; + const requestCSRFToken = req.headers['x-csrf-token'] || req.body?.csrfToken; + + if (storedCSRFToken && storedCSRFToken !== requestCSRFToken) { + return res.status(403).json({ + success: false, + message: 'Invalid CSRF token' + }); + } + if (password === accessPassword) { - // 设置Cookie以记住用户身份验证状态 + // 生成带盐的安全访问令牌 + const accessToken = generateAccessToken(accessPassword); const maxAge = 60 * 60 * 24 * 7; // 7天 + + // 设置访问令牌cookie(HttpOnly) res.setHeader('Set-Cookie', [ - `vercel_access_token=${accessPassword}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${process.env.NODE_ENV === 'production' ? '; Secure' : ''}` + setSecureCookie(req, 'vercel_access_token', accessToken, maxAge) ]); return res.status(200).json({ success: true, - message: 'Authentication successful' + message: 'Authentication successful' }); } else { return res.status(401).json({ @@ -49,7 +176,8 @@ export default function handler(req, res) { if (action === 'logout') { // 清除Cookie res.setHeader('Set-Cookie', [ - 'vercel_access_token=; HttpOnly; Path=/; Max-Age=0; SameSite=Strict' + 'vercel_access_token=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax', + 'csrf_token=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax' ]); return res.status(200).json({ @@ -57,6 +185,32 @@ export default function handler(req, res) { message: 'Logged out successfully' }); } + + if (action === 'csrf') { + // 生成新的CSRF token(非HttpOnly,前端可读取) + const csrfToken = generateSecureToken(16); + const isSecure = process.env.NODE_ENV === 'production' || + process.env.VERCEL_ENV === 'production' || + req.headers['x-forwarded-proto'] === 'https'; + + const cookieOptions = [ + `csrf_token=${csrfToken}`, + 'Path=/', + `Max-Age=${60 * 60 * 24}`, // 24小时 + 'SameSite=Lax' + ]; + + if (isSecure) { + cookieOptions.push('Secure'); + } + + res.setHeader('Set-Cookie', cookieOptions.join('; ')); + + return res.status(200).json({ + success: true, + csrfToken: csrfToken + }); + } } res.status(405).json({ error: 'Method not allowed' }); diff --git a/middleware.js b/middleware.js index 8b41fd5b..42ae4617 100644 --- a/middleware.js +++ b/middleware.js @@ -1,3 +1,5 @@ +const crypto = require('crypto'); + export const config = { matcher: [ /* @@ -10,6 +12,62 @@ export const config = { ], }; +// 验证访问令牌 +function verifyAccessToken(cookieHeader) { + const accessPassword = process.env.ACCESS_PASSWORD; + + // 如果没有设置密码,直接通过 + if (!accessPassword) { + return true; + } + + if (!cookieHeader) { + return false; + } + + const cookies = cookieHeader.split(';'); + const accessTokenCookie = cookies.find(cookie => + cookie.trim().startsWith('vercel_access_token=') + ); + + if (!accessTokenCookie) { + return false; + } + + const accessToken = accessTokenCookie.split('=')[1]; + + // 验证带盐的访问令牌 + if (!accessToken) return false; + + const parts = accessToken.split(':'); + if (parts.length !== 3) return false; + + const [salt, timestamp, hash] = parts; + + // 检查令牌时效性(7天) + const tokenAge = Date.now() - parseInt(timestamp); + if (tokenAge > 7 * 24 * 60 * 60 * 1000) { + return false; + } + + // 验证哈希 + const expectedHash = crypto.createHash('sha256').update(`${accessPassword}:${salt}:${timestamp}`).digest('hex'); + try { + return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expectedHash, 'hex')); + } catch (error) { + return false; + } +} + +// 添加安全头 +function addSecurityHeaders(headers) { + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('X-XSS-Protection', '1; mode=block'); + headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); +} + export default function middleware(request) { const url = new URL(request.url); const pathname = url.pathname; @@ -24,23 +82,16 @@ export default function middleware(request) { // 检查认证状态 const cookieHeader = request.headers.get('cookie'); - let authenticated = false; + const authenticated = verifyAccessToken(cookieHeader); - if (cookieHeader) { - const cookies = cookieHeader.split(';'); - const accessTokenCookie = cookies.find(cookie => - cookie.trim().startsWith('vercel_access_token=') - ); - - if (accessTokenCookie) { - const accessToken = accessTokenCookie.split('=')[1]; - authenticated = accessToken === accessPassword; - } - } - - // 如果已认证,允许访问 + // 如果已认证,允许访问并添加安全头 if (authenticated) { - return; // 什么都不返回,表示继续处理请求 + const response = new Response(null, { + status: 200, + headers: new Headers(request.headers), + }); + addSecurityHeaders(response.headers); + return response; } // 获取浏览器语言设置 @@ -48,13 +99,20 @@ export default function middleware(request) { const preferChinese = acceptLanguage.includes('zh'); // 未认证,返回认证页面 - return new Response(generateAuthPage(preferChinese), { + const response = new Response(generateAuthPage(preferChinese), { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', }, }); + + // 添加安全头 + addSecurityHeaders(response.headers); + + return response; } function generateAuthPage(isChinese = true) { @@ -248,9 +306,32 @@ function generateAuthPage(isChinese = true) { const isChinese = document.documentElement.lang === 'zh-CN'; const errorMessages = { network: '${text.errorNetwork}', - invalidPassword: isChinese ? '密码错误,请重试' : 'Invalid password, please try again' + invalidPassword: isChinese ? '密码错误,请重试' : 'Invalid password, please try again', + tooManyAttempts: isChinese ? '尝试次数过多,请稍后再试' : 'Too many attempts, please try again later' }; + // 从Cookie中获取CSRF token + function getCSRFToken() { + const cookies = document.cookie.split(';'); + const csrfCookie = cookies.find(cookie => + cookie.trim().startsWith('csrf_token=') + ); + return csrfCookie ? csrfCookie.split('=')[1].trim() : null; + } + + // 防抖函数 + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + form.addEventListener('submit', async (e) => { e.preventDefault(); @@ -264,11 +345,14 @@ function generateAuthPage(isChinese = true) { errorMessage.style.display = 'none'; try { - console.log('开始验证密码'); + // 获取CSRF token + const csrfToken = getCSRFToken(); + const response = await fetch('/api/auth', { method: 'POST', headers: { 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken || '' }, credentials: 'include', body: JSON.stringify({ @@ -284,11 +368,15 @@ function generateAuthPage(isChinese = true) { window.location.reload(); } else { // 认证失败 - console.log('认证失败', { message: data.message }); errorMessage.textContent = data.message || errorMessages.invalidPassword; errorMessage.style.display = 'block'; passwordInput.value = ''; passwordInput.focus(); + + // 如果是429错误,显示重试时间 + if (response.status === 429) { + errorMessage.textContent = errorMessages.tooManyAttempts; + } } } catch (error) { console.error('认证请求失败:', error); @@ -317,6 +405,19 @@ function generateAuthPage(isChinese = true) { errorMessage.style.display = 'none'; } }); + + // 页面加载时确保CSRF token存在 + window.addEventListener('load', () => { + if (!getCSRFToken()) { + // 如果没有CSRF token,获取一个 + fetch('/api/auth?action=csrf', { + method: 'GET', + credentials: 'include' + }).catch(() => { + // 忽略错误,首次访问是正常的 + }); + } + }); `;