mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
fix: 修复loading动画错误
This commit is contained in:
@@ -4901,9 +4901,13 @@ router.get('/oem-settings', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加 LDAP 启用状态到响应中
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: settings
|
data: {
|
||||||
|
...settings,
|
||||||
|
ldapEnabled: config.ldap && config.ldap.enabled === true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to get OEM settings:', error)
|
logger.error('❌ Failed to get OEM settings:', error)
|
||||||
|
|||||||
@@ -5,12 +5,87 @@ const userService = require('../services/userService')
|
|||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const config = require('../../config/config')
|
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')
|
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) => {
|
router.post('/login', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, password } = req.body
|
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) {
|
if (!username || !password) {
|
||||||
return res.status(400).json({
|
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) {
|
if (!config.userManagement.enabled) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
@@ -28,7 +115,7 @@ router.post('/login', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查LDAP是否启用
|
// 检查LDAP是否启用
|
||||||
if (!config.ldap.enabled) {
|
if (!config.ldap || !config.ldap.enabled) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
error: 'Service unavailable',
|
error: 'Service unavailable',
|
||||||
message: 'LDAP authentication is not enabled'
|
message: 'LDAP authentication is not enabled'
|
||||||
@@ -36,16 +123,19 @@ router.post('/login', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 尝试LDAP认证
|
// 尝试LDAP认证
|
||||||
const authResult = await ldapService.authenticateUserCredentials(username, password)
|
const authResult = await ldapService.authenticateUserCredentials(validatedUsername, password)
|
||||||
|
|
||||||
if (!authResult.success) {
|
if (!authResult.success) {
|
||||||
|
// 登录失败
|
||||||
|
logger.info(`🚫 Failed login attempt for user: ${validatedUsername} from IP: ${clientIp}`)
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Authentication failed',
|
error: 'Authentication failed',
|
||||||
message: authResult.message
|
message: authResult.message
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`✅ User login successful: ${username}`)
|
// 登录成功
|
||||||
|
logger.info(`✅ User login successful: ${validatedUsername} from IP: ${clientIp}`)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ const userService = require('./userService')
|
|||||||
|
|
||||||
class LdapService {
|
class LdapService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.config = config.ldap
|
this.config = config.ldap || {}
|
||||||
this.client = null
|
this.client = null
|
||||||
|
|
||||||
// 验证配置
|
// 验证配置 - 只有在 LDAP 配置存在且启用时才验证
|
||||||
if (this.config.enabled) {
|
if (this.config && this.config.enabled) {
|
||||||
this.validateConfiguration()
|
this.validateConfiguration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,17 @@ class LdapService {
|
|||||||
// 🔍 搜索用户
|
// 🔍 搜索用户
|
||||||
async searchUser(client, username) {
|
async searchUser(client, username) {
|
||||||
return new Promise((resolve, reject) => {
|
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 = {
|
const searchOptions = {
|
||||||
scope: 'sub',
|
scope: 'sub',
|
||||||
filter: searchFilter,
|
filter: searchFilter,
|
||||||
@@ -507,7 +517,15 @@ class LdapService {
|
|||||||
message: 'Authentication successful'
|
message: 'Authentication successful'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Authentication service unavailable'
|
message: 'Authentication service unavailable'
|
||||||
@@ -542,11 +560,28 @@ class LdapService {
|
|||||||
searchBase: this.config.server.searchBase
|
searchBase: this.config.server.searchBase
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `LDAP connection failed: ${error.message}`,
|
message: userMessage,
|
||||||
server: this.config.server.url
|
server: this.config.server.url.replace(/:[^:]*@/, ':***@') // 隐藏密码部分
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (client) {
|
if (client) {
|
||||||
|
|||||||
@@ -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"
|
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 管理后台按钮 -->
|
<!-- 用户登录按钮 (仅在 LDAP 启用时显示) -->
|
||||||
<router-link
|
<router-link
|
||||||
class="user-login-button flex items-center gap-2 rounded-xl px-3 py-2 text-white transition-all duration-300 md:px-4 md:py-2"
|
v-if="oemSettings.ldapEnabled"
|
||||||
|
class="user-login-button flex items-center gap-2 rounded-2xl px-4 py-2 text-white transition-all duration-300 md:px-5 md:py-2.5"
|
||||||
to="/user-login"
|
to="/user-login"
|
||||||
>
|
>
|
||||||
<i class="fas fa-user text-sm" />
|
<i class="fas fa-user text-sm md:text-base" />
|
||||||
<span class="text-xs font-medium md:text-sm">用户登录</span>
|
<span class="text-xs font-semibold tracking-wide md:text-sm">用户登录</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<!-- 管理后台按钮 -->
|
||||||
<router-link
|
<router-link
|
||||||
class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
|
class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
@@ -319,35 +321,68 @@ watch(apiKey, (newValue) => {
|
|||||||
/* 用户登录按钮 */
|
/* 用户登录按钮 */
|
||||||
.user-login-button {
|
.user-login-button {
|
||||||
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
|
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;
|
text-decoration: none;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 6px -1px rgba(52, 211, 153, 0.3),
|
0 4px 12px rgba(52, 211, 153, 0.25),
|
||||||
0 2px 4px -1px rgba(52, 211, 153, 0.1);
|
inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
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 {
|
.user-login-button::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: -100%;
|
left: 0;
|
||||||
width: 100%;
|
right: 0;
|
||||||
height: 100%;
|
bottom: 0;
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
|
||||||
transition: left 0.5s;
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-login-button:hover {
|
.user-login-button:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px) scale(1.02);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px rgba(52, 211, 153, 0.4),
|
0 8px 20px rgba(52, 211, 153, 0.35),
|
||||||
0 4px 6px -2px rgba(52, 211, 153, 0.15);
|
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-login-button:hover::before {
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 管理后台按钮 - 精致版本 */
|
/* 管理后台按钮 - 精致版本 */
|
||||||
|
|||||||
@@ -41,9 +41,8 @@
|
|||||||
|
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div v-if="loading" class="py-12 text-center">
|
<div v-if="loading" class="py-12 text-center">
|
||||||
<div class="loading-spinner mx-auto mb-4">
|
<div class="loading-spinner mx-auto mb-4"></div>
|
||||||
<p class="text-gray-500 dark:text-gray-400">正在加载设置...</p>
|
<p class="text-gray-500 dark:text-gray-400">正在加载设置...</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
@@ -982,14 +981,22 @@ const validateUrl = () => {
|
|||||||
const savePlatform = async () => {
|
const savePlatform = async () => {
|
||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
|
|
||||||
if (!platformForm.value.url) {
|
// Bark平台只需要deviceKey,其他平台需要URL
|
||||||
showToast('请输入Webhook URL', 'error')
|
if (platformForm.value.type === 'bark') {
|
||||||
return
|
if (!platformForm.value.deviceKey) {
|
||||||
}
|
showToast('请输入Bark设备密钥', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!platformForm.value.url) {
|
||||||
|
showToast('请输入Webhook URL', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (urlError.value) {
|
if (urlError.value) {
|
||||||
showToast('请输入有效的Webhook URL', 'error')
|
showToast('请输入有效的Webhook URL', 'error')
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
savingPlatform.value = true
|
savingPlatform.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user