From 86c243e1a491a748f19ecc6c15c7500fa5bf1f5a Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 2 Sep 2025 11:51:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dloading=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/admin.js | 6 +- src/routes/userRoutes.js | 96 +++++++++++++++++++++++- src/services/ldapService.js | 51 +++++++++++-- web/admin-spa/src/views/ApiStatsView.vue | 67 +++++++++++++---- web/admin-spa/src/views/SettingsView.vue | 27 ++++--- 5 files changed, 209 insertions(+), 38 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index 28469d7b..9eac7135 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -4901,9 +4901,13 @@ router.get('/oem-settings', async (req, res) => { } } + // 添加 LDAP 启用状态到响应中 return res.json({ success: true, - data: settings + data: { + ...settings, + ldapEnabled: config.ldap && config.ldap.enabled === true + } }) } catch (error) { logger.error('❌ Failed to get OEM settings:', error) diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 18074863..653e3c9e 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -5,12 +5,87 @@ const userService = require('../services/userService') const apiKeyService = require('../services/apiKeyService') const logger = require('../utils/logger') const config = require('../../config/config') +const inputValidator = require('../utils/inputValidator') +const { RateLimiterRedis } = require('rate-limiter-flexible') +const redis = require('../models/redis') const { authenticateUser, authenticateUserOrAdmin, requireAdmin } = require('../middleware/auth') +// 🚦 配置登录速率限制 +// 只基于IP地址限制,避免攻击者恶意锁定特定账户 + +// 延迟初始化速率限制器,确保 Redis 已连接 +let ipRateLimiter = null +let strictIpRateLimiter = null + +// 初始化速率限制器函数 +function initRateLimiters() { + if (!ipRateLimiter) { + try { + const redisClient = redis.getClientSafe() + + // IP地址速率限制 - 正常限制 + ipRateLimiter = new RateLimiterRedis({ + storeClient: redisClient, + keyPrefix: 'login_ip_limiter', + points: 30, // 每个IP允许30次尝试 + duration: 900, // 15分钟窗口期 + blockDuration: 900 // 超限后封禁15分钟 + }) + + // IP地址速率限制 - 严格限制(用于检测暴力破解) + strictIpRateLimiter = new RateLimiterRedis({ + storeClient: redisClient, + keyPrefix: 'login_ip_strict', + points: 100, // 每个IP允许100次尝试 + duration: 3600, // 1小时窗口期 + blockDuration: 3600 // 超限后封禁1小时 + }) + } catch (error) { + logger.error('❌ 初始化速率限制器失败:', error) + // 速率限制器初始化失败时继续运行,但记录错误 + } + } + return { ipRateLimiter, strictIpRateLimiter } +} + // 🔐 用户登录端点 router.post('/login', async (req, res) => { try { const { username, password } = req.body + const clientIp = req.ip || req.connection.remoteAddress || 'unknown' + + // 初始化速率限制器(如果尚未初始化) + const limiters = initRateLimiters() + + // 检查IP速率限制 - 基础限制 + if (limiters.ipRateLimiter) { + try { + await limiters.ipRateLimiter.consume(clientIp) + } catch (rateLimiterRes) { + const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 900 + logger.security(`🚫 Login rate limit exceeded for IP: ${clientIp}`) + res.set('Retry-After', String(retryAfter)) + return res.status(429).json({ + error: 'Too many requests', + message: `Too many login attempts from this IP. Please try again later.` + }) + } + } + + // 检查IP速率限制 - 严格限制(防止暴力破解) + if (limiters.strictIpRateLimiter) { + try { + await limiters.strictIpRateLimiter.consume(clientIp) + } catch (rateLimiterRes) { + const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 3600 + logger.security(`🚫 Strict rate limit exceeded for IP: ${clientIp} - possible brute force`) + res.set('Retry-After', String(retryAfter)) + return res.status(429).json({ + error: 'Too many requests', + message: 'Too many login attempts detected. Access temporarily blocked.' + }) + } + } if (!username || !password) { return res.status(400).json({ @@ -19,6 +94,18 @@ router.post('/login', async (req, res) => { }) } + // 验证输入格式 + let validatedUsername + try { + validatedUsername = inputValidator.validateUsername(username) + inputValidator.validatePassword(password) + } catch (validationError) { + return res.status(400).json({ + error: 'Invalid input', + message: validationError.message + }) + } + // 检查用户管理是否启用 if (!config.userManagement.enabled) { return res.status(503).json({ @@ -28,7 +115,7 @@ router.post('/login', async (req, res) => { } // 检查LDAP是否启用 - if (!config.ldap.enabled) { + if (!config.ldap || !config.ldap.enabled) { return res.status(503).json({ error: 'Service unavailable', message: 'LDAP authentication is not enabled' @@ -36,16 +123,19 @@ router.post('/login', async (req, res) => { } // 尝试LDAP认证 - const authResult = await ldapService.authenticateUserCredentials(username, password) + const authResult = await ldapService.authenticateUserCredentials(validatedUsername, password) if (!authResult.success) { + // 登录失败 + logger.info(`🚫 Failed login attempt for user: ${validatedUsername} from IP: ${clientIp}`) return res.status(401).json({ error: 'Authentication failed', message: authResult.message }) } - logger.info(`✅ User login successful: ${username}`) + // 登录成功 + logger.info(`✅ User login successful: ${validatedUsername} from IP: ${clientIp}`) res.json({ success: true, diff --git a/src/services/ldapService.js b/src/services/ldapService.js index 1586fd0a..75b4e704 100644 --- a/src/services/ldapService.js +++ b/src/services/ldapService.js @@ -5,11 +5,11 @@ const userService = require('./userService') class LdapService { constructor() { - this.config = config.ldap + this.config = config.ldap || {} this.client = null - // 验证配置 - if (this.config.enabled) { + // 验证配置 - 只有在 LDAP 配置存在且启用时才验证 + if (this.config && this.config.enabled) { this.validateConfiguration() } } @@ -219,7 +219,17 @@ class LdapService { // 🔍 搜索用户 async searchUser(client, username) { return new Promise((resolve, reject) => { - const searchFilter = this.config.server.searchFilter.replace('{{username}}', username) + // 防止LDAP注入:转义特殊字符 + // 根据RFC 4515,需要转义的特殊字符:* ( ) \ NUL + const escapedUsername = username + .replace(/\\/g, '\\5c') // 反斜杠必须先转义 + .replace(/\*/g, '\\2a') // 星号 + .replace(/\(/g, '\\28') // 左括号 + .replace(/\)/g, '\\29') // 右括号 + .replace(/\0/g, '\\00') // NUL字符 + .replace(/\//g, '\\2f') // 斜杠 + + const searchFilter = this.config.server.searchFilter.replace('{{username}}', escapedUsername) const searchOptions = { scope: 'sub', filter: searchFilter, @@ -507,7 +517,15 @@ class LdapService { message: 'Authentication successful' } } catch (error) { - logger.error('❌ LDAP authentication error:', error) + // 记录详细错误供调试,但不向用户暴露 + logger.error('❌ LDAP authentication error:', { + username: sanitizedUsername, + error: error.message, + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined + }) + + // 返回通用错误消息,避免信息泄露 + // 不要尝试解析具体的错误信息,因为不同LDAP服务器返回的格式不同 return { success: false, message: 'Authentication service unavailable' @@ -542,11 +560,28 @@ class LdapService { searchBase: this.config.server.searchBase } } catch (error) { - logger.error('❌ LDAP connection test failed:', error) + logger.error('❌ LDAP connection test failed:', { + error: error.message, + server: this.config.server.url, + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined + }) + + // 提供通用错误消息,避免泄露系统细节 + let userMessage = 'LDAP connection failed' + + // 对于某些已知错误类型,提供有用但不泄露细节的信息 + if (error.code === 'ECONNREFUSED') { + userMessage = 'Unable to connect to LDAP server' + } else if (error.code === 'ETIMEDOUT') { + userMessage = 'LDAP server connection timeout' + } else if (error.name === 'InvalidCredentialsError') { + userMessage = 'LDAP bind credentials are invalid' + } + return { success: false, - message: `LDAP connection failed: ${error.message}`, - server: this.config.server.url + message: userMessage, + server: this.config.server.url.replace(/:[^:]*@/, ':***@') // 隐藏密码部分 } } finally { if (client) { diff --git a/web/admin-spa/src/views/ApiStatsView.vue b/web/admin-spa/src/views/ApiStatsView.vue index 73c6e3fb..0de3ed13 100644 --- a/web/admin-spa/src/views/ApiStatsView.vue +++ b/web/admin-spa/src/views/ApiStatsView.vue @@ -20,14 +20,16 @@ class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600" /> - + - - 用户登录 + + 用户登录 + { /* 用户登录按钮 */ .user-login-button { background: linear-gradient(135deg, #34d399 0%, #10b981 100%); - border: 1px solid rgba(255, 255, 255, 0.2); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); text-decoration: none; box-shadow: - 0 4px 6px -1px rgba(52, 211, 153, 0.3), - 0 2px 4px -1px rgba(52, 211, 153, 0.1); + 0 4px 12px rgba(52, 211, 153, 0.25), + inset 0 1px 1px rgba(255, 255, 255, 0.2); position: relative; overflow: hidden; + font-weight: 600; +} + +/* 暗色模式下的用户登录按钮 */ +:global(.dark) .user-login-button { + background: linear-gradient(135deg, #34d399 0%, #10b981 100%); + border: 1px solid rgba(52, 211, 153, 0.4); + color: white; + box-shadow: + 0 4px 12px rgba(52, 211, 153, 0.3), + inset 0 1px 1px rgba(255, 255, 255, 0.1); } .user-login-button::before { content: ''; position: absolute; top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); - transition: left 0.5s; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, #10b981 0%, #34d399 100%); + opacity: 0; + transition: opacity 0.3s ease; } .user-login-button:hover { - transform: translateY(-2px); + transform: translateY(-2px) scale(1.02); box-shadow: - 0 10px 15px -3px rgba(52, 211, 153, 0.4), - 0 4px 6px -2px rgba(52, 211, 153, 0.15); + 0 8px 20px rgba(52, 211, 153, 0.35), + inset 0 1px 1px rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.4); } .user-login-button:hover::before { - left: 100%; + opacity: 1; +} + +/* 暗色模式下的悬停效果 */ +:global(.dark) .user-login-button:hover { + box-shadow: + 0 8px 20px rgba(52, 211, 153, 0.4), + inset 0 1px 1px rgba(255, 255, 255, 0.2); + border-color: rgba(52, 211, 153, 0.5); +} + +.user-login-button:active { + transform: translateY(-1px) scale(1); +} + +/* 确保图标和文字在所有模式下都清晰可见 */ +.user-login-button i, +.user-login-button span { + position: relative; + z-index: 1; } /* 管理后台按钮 - 精致版本 */ diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue index 15801feb..689f7fa5 100644 --- a/web/admin-spa/src/views/SettingsView.vue +++ b/web/admin-spa/src/views/SettingsView.vue @@ -41,9 +41,8 @@
-
-

正在加载设置...

-
+
+

正在加载设置...

@@ -982,14 +981,22 @@ const validateUrl = () => { const savePlatform = async () => { if (!isMounted.value) return - if (!platformForm.value.url) { - showToast('请输入Webhook URL', 'error') - return - } + // Bark平台只需要deviceKey,其他平台需要URL + if (platformForm.value.type === 'bark') { + if (!platformForm.value.deviceKey) { + showToast('请输入Bark设备密钥', 'error') + return + } + } else { + if (!platformForm.value.url) { + showToast('请输入Webhook URL', 'error') + return + } - if (urlError.value) { - showToast('请输入有效的Webhook URL', 'error') - return + if (urlError.value) { + showToast('请输入有效的Webhook URL', 'error') + return + } } savingPlatform.value = true