From eb150b493702e3602e284c183250bcddd1715e0d Mon Sep 17 00:00:00 2001 From: Feng Yue <2525275@gmail.com> Date: Wed, 13 Aug 2025 11:30:00 +0800 Subject: [PATCH 01/85] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=92=8CLDAP=E8=AE=A4=E8=AF=81=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增LDAP认证服务支持用户登录验证 - 实现用户服务包含会话管理和权限控制 - 添加用户专用路由和API端点 - 扩展认证中间件支持用户和管理员双重身份 - 新增用户仪表板、API密钥管理和使用统计界面 - 完善前端用户管理组件和路由配置 - 支持用户自助API密钥创建和管理 - 添加管理员用户管理功能包含角色权限控制 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- config/config.example.js | 30 + package.json | 1 + src/app.js | 2 + src/middleware/auth.js | 230 +++++++ src/routes/userRoutes.js | 651 ++++++++++++++++++ src/services/apiKeyService.js | 199 +++++- src/services/ldapService.js | 296 ++++++++ src/services/userService.js | 408 +++++++++++ .../src/components/admin/ChangeRoleModal.vue | 208 ++++++ .../components/admin/UserUsageStatsModal.vue | 314 +++++++++ .../src/components/layout/MainLayout.vue | 1 + .../src/components/layout/TabBar.vue | 1 + .../src/components/user/CreateApiKeyModal.vue | 281 ++++++++ .../components/user/UserApiKeysManager.vue | 287 ++++++++ .../src/components/user/UserUsageStats.vue | 276 ++++++++ .../src/components/user/ViewApiKeyModal.vue | 248 +++++++ web/admin-spa/src/router/index.js | 58 +- web/admin-spa/src/stores/user.js | 185 +++++ web/admin-spa/src/views/UserDashboardView.vue | 266 +++++++ web/admin-spa/src/views/UserLoginView.vue | 146 ++++ .../src/views/UserManagementView.vue | 511 ++++++++++++++ 21 files changed, 4596 insertions(+), 3 deletions(-) create mode 100644 src/routes/userRoutes.js create mode 100644 src/services/ldapService.js create mode 100644 src/services/userService.js create mode 100644 web/admin-spa/src/components/admin/ChangeRoleModal.vue create mode 100644 web/admin-spa/src/components/admin/UserUsageStatsModal.vue create mode 100644 web/admin-spa/src/components/user/CreateApiKeyModal.vue create mode 100644 web/admin-spa/src/components/user/UserApiKeysManager.vue create mode 100644 web/admin-spa/src/components/user/UserUsageStats.vue create mode 100644 web/admin-spa/src/components/user/ViewApiKeyModal.vue create mode 100644 web/admin-spa/src/stores/user.js create mode 100644 web/admin-spa/src/views/UserDashboardView.vue create mode 100644 web/admin-spa/src/views/UserLoginView.vue create mode 100644 web/admin-spa/src/views/UserManagementView.vue diff --git a/config/config.example.js b/config/config.example.js index b3342c08..87f0218d 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -120,6 +120,36 @@ const config = { allowCustomClients: process.env.ALLOW_CUSTOM_CLIENTS === 'true' }, + // 🔐 LDAP 认证配置 + ldap: { + enabled: process.env.LDAP_ENABLED === 'true', + server: { + url: process.env.LDAP_URL || 'ldap://localhost:389', + bindDN: process.env.LDAP_BIND_DN || 'cn=admin,dc=example,dc=com', + bindCredentials: process.env.LDAP_BIND_PASSWORD || 'admin', + searchBase: process.env.LDAP_SEARCH_BASE || 'dc=example,dc=com', + searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})', + searchAttributes: process.env.LDAP_SEARCH_ATTRIBUTES ? process.env.LDAP_SEARCH_ATTRIBUTES.split(',') : ['dn', 'uid', 'cn', 'mail', 'givenName', 'sn'], + timeout: parseInt(process.env.LDAP_TIMEOUT) || 5000, + connectTimeout: parseInt(process.env.LDAP_CONNECT_TIMEOUT) || 10000 + }, + userMapping: { + username: process.env.LDAP_USER_ATTR_USERNAME || 'uid', + displayName: process.env.LDAP_USER_ATTR_DISPLAY_NAME || 'cn', + email: process.env.LDAP_USER_ATTR_EMAIL || 'mail', + firstName: process.env.LDAP_USER_ATTR_FIRST_NAME || 'givenName', + lastName: process.env.LDAP_USER_ATTR_LAST_NAME || 'sn' + } + }, + + // 👥 用户管理配置 + userManagement: { + enabled: process.env.USER_MANAGEMENT_ENABLED === 'true', + defaultUserRole: process.env.DEFAULT_USER_ROLE || 'user', + userSessionTimeout: parseInt(process.env.USER_SESSION_TIMEOUT) || 86400000, // 24小时 + maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 5 + }, + // 🛠️ 开发配置 development: { debug: process.env.DEBUG === 'true', diff --git a/package.json b/package.json index 48d7c604..86424cea 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", "ioredis": "^5.3.2", + "ldapjs": "^3.0.7", "morgan": "^1.10.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", diff --git a/src/app.js b/src/app.js index cdebed53..63cc1c47 100644 --- a/src/app.js +++ b/src/app.js @@ -20,6 +20,7 @@ const geminiRoutes = require('./routes/geminiRoutes') const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes') const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') const openaiRoutes = require('./routes/openaiRoutes') +const userRoutes = require('./routes/userRoutes') // Import middleware const { @@ -229,6 +230,7 @@ class Application { this.app.use('/api', apiRoutes) this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同 this.app.use('/admin', adminRoutes) + this.app.use('/users', userRoutes) // 使用 web 路由(包含 auth 和页面重定向) this.app.use('/web', webRoutes) this.app.use('/apiStats', apiStatsRoutes) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 660bb72d..1752b0da 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,4 +1,5 @@ const apiKeyService = require('../services/apiKeyService') +const userService = require('../services/userService') const logger = require('../utils/logger') const redis = require('../models/redis') const { RateLimiterRedis } = require('rate-limiter-flexible') @@ -446,6 +447,231 @@ const authenticateAdmin = async (req, res, next) => { } } +// 👤 用户验证中间件 +const authenticateUser = async (req, res, next) => { + const startTime = Date.now() + + try { + // 安全提取用户session token,支持多种方式 + const sessionToken = + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.cookies?.userToken || + req.headers['x-user-token'] + + if (!sessionToken) { + logger.security(`🔒 Missing user session token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Missing user session token', + message: 'Please login to access this resource' + }) + } + + // 基本token格式验证 + if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) { + logger.security(`🔒 Invalid user session token format from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid session token format', + message: 'Session token format is invalid' + }) + } + + // 验证用户会话 + const sessionValidation = await userService.validateUserSession(sessionToken) + + if (!sessionValidation) { + logger.security(`🔒 Invalid user session token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid session token', + message: 'Invalid or expired user session' + }) + } + + const { session, user } = sessionValidation + + // 检查用户是否被禁用 + if (!user.isActive) { + logger.security(`🔒 Disabled user login attempt: ${user.username} from ${req.ip || 'unknown'}`) + return res.status(403).json({ + error: 'Account disabled', + message: 'Your account has been disabled. Please contact administrator.' + }) + } + + // 设置用户信息(只包含必要信息) + req.user = { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + sessionToken: sessionToken, + sessionCreatedAt: session.createdAt + } + + const authDuration = Date.now() - startTime + logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) + + return next() + } catch (error) { + const authDuration = Date.now() - startTime + logger.error(`❌ User authentication error (${authDuration}ms):`, { + error: error.message, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during user authentication' + }) + } +} + +// 👤 用户或管理员验证中间件(支持两种身份) +const authenticateUserOrAdmin = async (req, res, next) => { + const startTime = Date.now() + + try { + // 检查是否有管理员token + const adminToken = + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.cookies?.adminToken || + req.headers['x-admin-token'] + + // 检查是否有用户session token + const userToken = + req.headers['x-user-token'] || + req.cookies?.userToken || + (!adminToken ? req.headers['authorization']?.replace(/^Bearer\s+/i, '') : null) + + // 优先尝试管理员认证 + if (adminToken) { + try { + const adminSession = await redis.getSession(adminToken) + if (adminSession && Object.keys(adminSession).length > 0) { + req.admin = { + id: adminSession.adminId || 'admin', + username: adminSession.username, + sessionId: adminToken, + loginTime: adminSession.loginTime + } + req.userType = 'admin' + + const authDuration = Date.now() - startTime + logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`) + return next() + } + } catch (error) { + logger.debug('Admin authentication failed, trying user authentication:', error.message) + } + } + + // 尝试用户认证 + if (userToken) { + try { + const sessionValidation = await userService.validateUserSession(userToken) + if (sessionValidation) { + const { session, user } = sessionValidation + + if (user.isActive) { + req.user = { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + sessionToken: userToken, + sessionCreatedAt: session.createdAt + } + req.userType = 'user' + + const authDuration = Date.now() - startTime + logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) + return next() + } + } + } catch (error) { + logger.debug('User authentication failed:', error.message) + } + } + + // 如果都失败了,返回未授权 + logger.security(`🔒 Authentication failed from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Authentication required', + message: 'Please login as user or admin to access this resource' + }) + + } catch (error) { + const authDuration = Date.now() - startTime + logger.error(`❌ User/Admin authentication error (${authDuration}ms):`, { + error: error.message, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during authentication' + }) + } +} + +// 🛡️ 权限检查中间件 +const requireRole = (allowedRoles) => { + return (req, res, next) => { + // 管理员始终有权限 + if (req.admin) { + return next() + } + + // 检查用户角色 + if (req.user) { + const userRole = req.user.role + const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles] + + if (allowed.includes(userRole)) { + return next() + } else { + logger.security(`🚫 Access denied for user ${req.user.username} (role: ${userRole}) to ${req.originalUrl}`) + return res.status(403).json({ + error: 'Insufficient permissions', + message: `This resource requires one of the following roles: ${allowed.join(', ')}` + }) + } + } + + return res.status(401).json({ + error: 'Authentication required', + message: 'Please login to access this resource' + }) + } +} + +// 🔒 管理员权限检查中间件 +const requireAdmin = (req, res, next) => { + if (req.admin) { + return next() + } + + // 检查是否是admin角色的用户 + if (req.user && req.user.role === 'admin') { + return next() + } + + logger.security(`🚫 Admin access denied for ${req.user?.username || 'unknown'} from ${req.ip || 'unknown'}`) + return res.status(403).json({ + error: 'Admin access required', + message: 'This resource requires administrator privileges' + }) +} + // 注意:使用统计现在直接在/api/v1/messages路由中处理, // 以便从Claude API响应中提取真实的usage数据 @@ -796,6 +1022,10 @@ const requestSizeLimit = (req, res, next) => { module.exports = { authenticateApiKey, authenticateAdmin, + authenticateUser, + authenticateUserOrAdmin, + requireRole, + requireAdmin, corsMiddleware, requestLogger, securityMiddleware, diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js new file mode 100644 index 00000000..68e2910b --- /dev/null +++ b/src/routes/userRoutes.js @@ -0,0 +1,651 @@ +const express = require('express') +const router = express.Router() +const ldapService = require('../services/ldapService') +const userService = require('../services/userService') +const apiKeyService = require('../services/apiKeyService') +const logger = require('../utils/logger') +const config = require('../../config/config') +const { + authenticateUser, + authenticateUserOrAdmin, + requireAdmin, + requireRole +} = require('../middleware/auth') + +// 🔐 用户登录端点 +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body + + if (!username || !password) { + return res.status(400).json({ + error: 'Missing credentials', + message: 'Username and password are required' + }) + } + + // 检查用户管理是否启用 + if (!config.userManagement.enabled) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'User management is not enabled' + }) + } + + // 检查LDAP是否启用 + if (!config.ldap.enabled) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'LDAP authentication is not enabled' + }) + } + + // 尝试LDAP认证 + const authResult = await ldapService.authenticateUserCredentials(username, password) + + if (!authResult.success) { + return res.status(401).json({ + error: 'Authentication failed', + message: authResult.message + }) + } + + logger.info(`✅ User login successful: ${username}`) + + res.json({ + success: true, + message: 'Login successful', + user: { + id: authResult.user.id, + username: authResult.user.username, + email: authResult.user.email, + displayName: authResult.user.displayName, + firstName: authResult.user.firstName, + lastName: authResult.user.lastName, + role: authResult.user.role + }, + sessionToken: authResult.sessionToken + }) + + } catch (error) { + logger.error('❌ User login error:', error) + res.status(500).json({ + error: 'Login error', + message: 'Internal server error during login' + }) + } +}) + +// 🚪 用户登出端点 +router.post('/logout', authenticateUser, async (req, res) => { + try { + await userService.invalidateUserSession(req.user.sessionToken) + + logger.info(`👋 User logout: ${req.user.username}`) + + res.json({ + success: true, + message: 'Logout successful' + }) + + } catch (error) { + logger.error('❌ User logout error:', error) + res.status(500).json({ + error: 'Logout error', + message: 'Internal server error during logout' + }) + } +}) + +// 👤 获取当前用户信息 +router.get('/profile', authenticateUser, async (req, res) => { + try { + const user = await userService.getUserById(req.user.id) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User profile not found' + }) + } + + res.json({ + success: true, + user: { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + isActive: user.isActive, + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt, + apiKeyCount: user.apiKeyCount, + totalUsage: user.totalUsage + } + }) + + } catch (error) { + logger.error('❌ Get user profile error:', error) + res.status(500).json({ + error: 'Profile error', + message: 'Failed to retrieve user profile' + }) + } +}) + +// 🔑 获取用户的API Keys +router.get('/api-keys', authenticateUser, async (req, res) => { + try { + const apiKeys = await apiKeyService.getUserApiKeys(req.user.id) + + // 移除敏感信息 + const safeApiKeys = apiKeys.map(key => ({ + id: key.id, + name: key.name, + description: key.description, + tokenLimit: key.tokenLimit, + isActive: key.isActive, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + expiresAt: key.expiresAt, + usage: key.usage, + dailyCost: key.dailyCost, + dailyCostLimit: key.dailyCostLimit, + // 不返回实际的key值,只返回前缀和后几位 + keyPreview: key.key ? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}` : null + })) + + res.json({ + success: true, + apiKeys: safeApiKeys, + total: safeApiKeys.length + }) + + } catch (error) { + logger.error('❌ Get user API keys error:', error) + res.status(500).json({ + error: 'API Keys error', + message: 'Failed to retrieve API keys' + }) + } +}) + +// 🔑 创建新的API Key +router.post('/api-keys', authenticateUser, async (req, res) => { + try { + const { name, description, tokenLimit, expiresAt, dailyCostLimit } = req.body + + if (!name || !name.trim()) { + return res.status(400).json({ + error: 'Missing name', + message: 'API key name is required' + }) + } + + // 检查用户API Key数量限制 + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id) + if (userApiKeys.length >= config.userManagement.maxApiKeysPerUser) { + return res.status(400).json({ + error: 'API key limit exceeded', + message: `You can only have up to ${config.userManagement.maxApiKeysPerUser} API keys` + }) + } + + // 创建API Key数据 + const apiKeyData = { + name: name.trim(), + description: description?.trim() || '', + userId: req.user.id, + userUsername: req.user.username, + tokenLimit: tokenLimit || null, + expiresAt: expiresAt || null, + dailyCostLimit: dailyCostLimit || null, + createdBy: 'user', + permissions: ['messages'] // 用户创建的API Key默认只有messages权限 + } + + const newApiKey = await apiKeyService.createApiKey(apiKeyData) + + // 更新用户API Key数量 + await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length + 1) + + logger.info(`🔑 User ${req.user.username} created API key: ${name}`) + + res.status(201).json({ + success: true, + message: 'API key created successfully', + apiKey: { + id: newApiKey.id, + name: newApiKey.name, + description: newApiKey.description, + key: newApiKey.key, // 只在创建时返回完整key + tokenLimit: newApiKey.tokenLimit, + expiresAt: newApiKey.expiresAt, + dailyCostLimit: newApiKey.dailyCostLimit, + createdAt: newApiKey.createdAt + } + }) + + } catch (error) { + logger.error('❌ Create user API key error:', error) + res.status(500).json({ + error: 'API Key creation error', + message: 'Failed to create API key' + }) + } +}) + +// 🔑 重新生成API Key +router.post('/api-keys/:keyId/regenerate', authenticateUser, async (req, res) => { + try { + const { keyId } = req.params + + // 检查API Key是否属于当前用户 + const existingKey = await apiKeyService.getApiKeyById(keyId) + if (!existingKey || existingKey.userId !== req.user.id) { + return res.status(404).json({ + error: 'API key not found', + message: 'API key not found or you do not have permission to access it' + }) + } + + const newKey = await apiKeyService.regenerateApiKey(keyId) + + logger.info(`🔄 User ${req.user.username} regenerated API key: ${existingKey.name}`) + + res.json({ + success: true, + message: 'API key regenerated successfully', + apiKey: { + id: newKey.id, + name: newKey.name, + key: newKey.key, // 返回新的key + updatedAt: newKey.updatedAt + } + }) + + } catch (error) { + logger.error('❌ Regenerate user API key error:', error) + res.status(500).json({ + error: 'API Key regeneration error', + message: 'Failed to regenerate API key' + }) + } +}) + +// 🗑️ 删除API Key +router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => { + try { + const { keyId } = req.params + + // 检查API Key是否属于当前用户 + const existingKey = await apiKeyService.getApiKeyById(keyId) + if (!existingKey || existingKey.userId !== req.user.id) { + return res.status(404).json({ + error: 'API key not found', + message: 'API key not found or you do not have permission to access it' + }) + } + + await apiKeyService.deleteApiKey(keyId) + + // 更新用户API Key数量 + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id) + await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length) + + logger.info(`🗑️ User ${req.user.username} deleted API key: ${existingKey.name}`) + + res.json({ + success: true, + message: 'API key deleted successfully' + }) + + } catch (error) { + logger.error('❌ Delete user API key error:', error) + res.status(500).json({ + error: 'API Key deletion error', + message: 'Failed to delete API key' + }) + } +}) + +// 📊 获取用户使用统计 +router.get('/usage-stats', authenticateUser, async (req, res) => { + try { + const { period = 'week', model } = req.query + + // 获取用户的API Keys + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id) + const apiKeyIds = userApiKeys.map(key => key.id) + + if (apiKeyIds.length === 0) { + return res.json({ + success: true, + stats: { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + }) + } + + // 获取使用统计 + const stats = await apiKeyService.getUsageStats(apiKeyIds, { period, model }) + + res.json({ + success: true, + stats + }) + + } catch (error) { + logger.error('❌ Get user usage stats error:', error) + res.status(500).json({ + error: 'Usage stats error', + message: 'Failed to retrieve usage statistics' + }) + } +}) + +// === 管理员用户管理端点 === + +// 📋 获取用户列表(管理员) +router.get('/', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { page = 1, limit = 20, role, isActive, search } = req.query + + const options = { + page: parseInt(page), + limit: parseInt(limit), + role, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined + } + + const result = await userService.getAllUsers(options) + + // 如果有搜索条件,进行过滤 + let filteredUsers = result.users + if (search) { + const searchLower = search.toLowerCase() + filteredUsers = result.users.filter(user => + user.username.toLowerCase().includes(searchLower) || + user.displayName.toLowerCase().includes(searchLower) || + user.email.toLowerCase().includes(searchLower) + ) + } + + res.json({ + success: true, + users: filteredUsers, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages + } + }) + + } catch (error) { + logger.error('❌ Get users list error:', error) + res.status(500).json({ + error: 'Users list error', + message: 'Failed to retrieve users list' + }) + } +}) + +// 👤 获取特定用户信息(管理员) +router.get('/:userId', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + // 获取用户的API Keys + const apiKeys = await apiKeyService.getUserApiKeys(userId) + + res.json({ + success: true, + user: { + ...user, + apiKeys: apiKeys.map(key => ({ + id: key.id, + name: key.name, + description: key.description, + isActive: key.isActive, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + usage: key.usage, + keyPreview: key.key ? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}` : null + })) + } + }) + + } catch (error) { + logger.error('❌ Get user details error:', error) + res.status(500).json({ + error: 'User details error', + message: 'Failed to retrieve user details' + }) + } +}) + +// 🔄 更新用户状态(管理员) +router.patch('/:userId/status', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { isActive } = req.body + + if (typeof isActive !== 'boolean') { + return res.status(400).json({ + error: 'Invalid status', + message: 'isActive must be a boolean value' + }) + } + + const updatedUser = await userService.updateUserStatus(userId, isActive) + + const adminUser = req.admin?.username || req.user?.username + logger.info(`🔄 Admin ${adminUser} ${isActive ? 'enabled' : 'disabled'} user: ${updatedUser.username}`) + + res.json({ + success: true, + message: `User ${isActive ? 'enabled' : 'disabled'} successfully`, + user: { + id: updatedUser.id, + username: updatedUser.username, + isActive: updatedUser.isActive, + updatedAt: updatedUser.updatedAt + } + }) + + } catch (error) { + logger.error('❌ Update user status error:', error) + res.status(500).json({ + error: 'Update status error', + message: error.message || 'Failed to update user status' + }) + } +}) + +// 🔄 更新用户角色(管理员) +router.patch('/:userId/role', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { role } = req.body + + const validRoles = ['user', 'admin'] + if (!role || !validRoles.includes(role)) { + return res.status(400).json({ + error: 'Invalid role', + message: `Role must be one of: ${validRoles.join(', ')}` + }) + } + + const updatedUser = await userService.updateUserRole(userId, role) + + const adminUser = req.admin?.username || req.user?.username + logger.info(`🔄 Admin ${adminUser} changed user ${updatedUser.username} role to: ${role}`) + + res.json({ + success: true, + message: `User role updated to ${role} successfully`, + user: { + id: updatedUser.id, + username: updatedUser.username, + role: updatedUser.role, + updatedAt: updatedUser.updatedAt + } + }) + + } catch (error) { + logger.error('❌ Update user role error:', error) + res.status(500).json({ + error: 'Update role error', + message: error.message || 'Failed to update user role' + }) + } +}) + +// 🔑 禁用用户的所有API Keys(管理员) +router.post('/:userId/disable-keys', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + const result = await apiKeyService.disableUserApiKeys(userId) + + const adminUser = req.admin?.username || req.user?.username + logger.info(`🔑 Admin ${adminUser} disabled all API keys for user: ${user.username}`) + + res.json({ + success: true, + message: `Disabled ${result.count} API keys for user ${user.username}`, + disabledCount: result.count + }) + + } catch (error) { + logger.error('❌ Disable user API keys error:', error) + res.status(500).json({ + error: 'Disable keys error', + message: 'Failed to disable user API keys' + }) + } +}) + +// 📊 获取用户使用统计(管理员) +router.get('/:userId/usage-stats', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { period = 'week', model } = req.query + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + // 获取用户的API Keys + const userApiKeys = await apiKeyService.getUserApiKeys(userId) + const apiKeyIds = userApiKeys.map(key => key.id) + + if (apiKeyIds.length === 0) { + return res.json({ + success: true, + user: { + id: user.id, + username: user.username, + displayName: user.displayName + }, + stats: { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + }) + } + + // 获取使用统计 + const stats = await apiKeyService.getUsageStats(apiKeyIds, { period, model }) + + res.json({ + success: true, + user: { + id: user.id, + username: user.username, + displayName: user.displayName + }, + stats + }) + + } catch (error) { + logger.error('❌ Get user usage stats (admin) error:', error) + res.status(500).json({ + error: 'Usage stats error', + message: 'Failed to retrieve user usage statistics' + }) + } +}) + +// 📊 获取用户管理统计(管理员) +router.get('/stats/overview', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const stats = await userService.getUserStats() + + res.json({ + success: true, + stats + }) + + } catch (error) { + logger.error('❌ Get user stats overview error:', error) + res.status(500).json({ + error: 'Stats error', + message: 'Failed to retrieve user statistics' + }) + } +}) + +// 🔧 测试LDAP连接(管理员) +router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const testResult = await ldapService.testConnection() + + res.json({ + success: true, + ldapTest: testResult, + config: ldapService.getConfigInfo() + }) + + } catch (error) { + logger.error('❌ LDAP test error:', error) + res.status(500).json({ + error: 'LDAP test error', + message: 'Failed to test LDAP connection' + }) + } +}) + +module.exports = router \ No newline at end of file diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 4309e665..082e992e 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -62,7 +62,9 @@ class ApiKeyService { createdAt: new Date().toISOString(), lastUsedAt: '', expiresAt: expiresAt || '', - createdBy: 'admin' // 可以根据需要扩展用户系统 + createdBy: options.createdBy || 'admin', + userId: options.userId || '', + userUsername: options.userUsername || '' } // 保存API Key数据并建立哈希映射 @@ -478,6 +480,201 @@ class ApiKeyService { return await redis.getAllAccountsUsageStats() } + // === 用户相关方法 === + + // 🔑 创建API Key(支持用户) + async createApiKey(options = {}) { + return await this.generateApiKey(options) + } + + // 👤 获取用户的API Keys + async getUserApiKeys(userId) { + try { + const allKeys = await redis.getAllApiKeys() + return allKeys + .filter(key => key.userId === userId) + .map(key => ({ + id: key.id, + name: key.name, + description: key.description, + key: key.apiKey ? `${this.prefix}****${key.apiKey.slice(-4)}` : null, // 只显示前缀和后4位 + tokenLimit: parseInt(key.tokenLimit || 0), + isActive: key.isActive === 'true', + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + expiresAt: key.expiresAt, + usage: key.usage || { requests: 0, inputTokens: 0, outputTokens: 0, totalCost: 0 }, + dailyCost: key.dailyCost || 0, + dailyCostLimit: parseFloat(key.dailyCostLimit || 0), + userId: key.userId, + userUsername: key.userUsername, + createdBy: key.createdBy + })) + } catch (error) { + logger.error('❌ Failed to get user API keys:', error) + return [] + } + } + + // 🔍 通过ID获取API Key(检查权限) + async getApiKeyById(keyId, userId = null) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData) return null + + // 如果指定了用户ID,检查权限 + if (userId && keyData.userId !== userId) { + return null + } + + return { + id: keyData.id, + name: keyData.name, + description: keyData.description, + key: keyData.apiKey, + tokenLimit: parseInt(keyData.tokenLimit || 0), + isActive: keyData.isActive === 'true', + createdAt: keyData.createdAt, + lastUsedAt: keyData.lastUsedAt, + expiresAt: keyData.expiresAt, + userId: keyData.userId, + userUsername: keyData.userUsername, + createdBy: keyData.createdBy, + permissions: keyData.permissions, + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0) + } + } catch (error) { + logger.error('❌ Failed to get API key by ID:', error) + return null + } + } + + // 🔄 重新生成API Key + async regenerateApiKey(keyId) { + try { + const existingKey = await redis.getApiKey(keyId) + if (!existingKey) { + throw new Error('API key not found') + } + + // 生成新的key + const newApiKey = `${this.prefix}${this._generateSecretKey()}` + const newHashedKey = this._hashApiKey(newApiKey) + + // 删除旧的哈希映射 + const oldHashedKey = existingKey.apiKey + await redis.deleteApiKeyHash(oldHashedKey) + + // 更新key数据 + const updatedKeyData = { + ...existingKey, + apiKey: newHashedKey, + updatedAt: new Date().toISOString() + } + + // 保存新数据并建立新的哈希映射 + await redis.setApiKey(keyId, updatedKeyData, newHashedKey) + + logger.info(`🔄 Regenerated API key: ${existingKey.name} (${keyId})`) + + return { + id: keyId, + name: existingKey.name, + key: newApiKey, // 返回完整的新key + updatedAt: updatedKeyData.updatedAt + } + } catch (error) { + logger.error('❌ Failed to regenerate API key:', error) + throw error + } + } + + // 🗑️ 删除API Key + async deleteApiKey(keyId) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData) { + throw new Error('API key not found') + } + + // 删除key数据和哈希映射 + await redis.deleteApiKey(keyId) + await redis.deleteApiKeyHash(keyData.apiKey) + + logger.info(`🗑️ Deleted API key: ${keyData.name} (${keyId})`) + return true + } catch (error) { + logger.error('❌ Failed to delete API key:', error) + throw error + } + } + + // 🚫 禁用用户的所有API Keys + async disableUserApiKeys(userId) { + try { + const userKeys = await this.getUserApiKeys(userId) + let disabledCount = 0 + + for (const key of userKeys) { + if (key.isActive) { + await this.updateApiKey(key.id, { isActive: false }) + disabledCount++ + } + } + + logger.info(`🚫 Disabled ${disabledCount} API keys for user: ${userId}`) + return { count: disabledCount } + } catch (error) { + logger.error('❌ Failed to disable user API keys:', error) + throw error + } + } + + // 📊 获取使用统计(支持多个API Key) + async getUsageStats(keyIds, options = {}) { + try { + if (!Array.isArray(keyIds)) { + keyIds = [keyIds] + } + + const { period = 'week', model } = options + const stats = { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + + // 汇总所有API Key的统计数据 + for (const keyId of keyIds) { + const keyStats = await redis.getUsageStats(keyId) + if (keyStats) { + stats.totalRequests += keyStats.requests || 0 + stats.totalInputTokens += keyStats.inputTokens || 0 + stats.totalOutputTokens += keyStats.outputTokens || 0 + stats.totalCost += keyStats.totalCost || 0 + } + } + + // TODO: 实现日期范围和模型统计 + // 这里可以根据需要添加更详细的统计逻辑 + + return stats + } catch (error) { + logger.error('❌ Failed to get usage stats:', error) + return { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + } + } + // 🧹 清理过期的API Keys async cleanupExpiredKeys() { try { diff --git a/src/services/ldapService.js b/src/services/ldapService.js new file mode 100644 index 00000000..4d8238be --- /dev/null +++ b/src/services/ldapService.js @@ -0,0 +1,296 @@ +const ldap = require('ldapjs') +const logger = require('../utils/logger') +const config = require('../../config/config') +const userService = require('./userService') + +class LdapService { + constructor() { + this.config = config.ldap + this.client = null + } + + // 🔗 创建LDAP客户端连接 + createClient() { + try { + const client = ldap.createClient({ + url: this.config.server.url, + timeout: this.config.server.timeout, + connectTimeout: this.config.server.connectTimeout, + reconnect: true + }) + + // 设置错误处理 + client.on('error', (err) => { + logger.error('🔌 LDAP client error:', err) + }) + + client.on('connect', () => { + logger.info('🔗 LDAP client connected successfully') + }) + + client.on('connectTimeout', () => { + logger.warn('⏱️ LDAP connection timeout') + }) + + return client + } catch (error) { + logger.error('❌ Failed to create LDAP client:', error) + throw error + } + } + + // 🔒 绑定LDAP连接(管理员认证) + async bindClient(client) { + return new Promise((resolve, reject) => { + client.bind(this.config.server.bindDN, this.config.server.bindCredentials, (err) => { + if (err) { + logger.error('❌ LDAP bind failed:', err) + reject(err) + } else { + logger.debug('🔑 LDAP bind successful') + resolve() + } + }) + }) + } + + // 🔍 搜索用户 + async searchUser(client, username) { + return new Promise((resolve, reject) => { + const searchFilter = this.config.server.searchFilter.replace('{{username}}', username) + const searchOptions = { + scope: 'sub', + filter: searchFilter, + attributes: this.config.server.searchAttributes + } + + logger.debug(`🔍 Searching for user: ${username} with filter: ${searchFilter}`) + + const entries = [] + + client.search(this.config.server.searchBase, searchOptions, (err, res) => { + if (err) { + logger.error('❌ LDAP search error:', err) + reject(err) + return + } + + res.on('searchEntry', (entry) => { + entries.push(entry) + }) + + res.on('searchReference', (referral) => { + logger.debug('🔗 LDAP search referral:', referral.uris) + }) + + res.on('error', (err) => { + logger.error('❌ LDAP search result error:', err) + reject(err) + }) + + res.on('end', (result) => { + logger.debug(`✅ LDAP search completed. Status: ${result.status}, Found ${entries.length} entries`) + + if (entries.length === 0) { + resolve(null) + } else if (entries.length === 1) { + resolve(entries[0]) + } else { + logger.warn(`⚠️ Multiple LDAP entries found for username: ${username}`) + resolve(entries[0]) // 使用第一个结果 + } + }) + }) + }) + } + + // 🔐 验证用户密码 + async authenticateUser(userDN, password) { + return new Promise((resolve, reject) => { + const authClient = this.createClient() + + authClient.bind(userDN, password, (err) => { + authClient.unbind() // 立即关闭认证客户端 + + if (err) { + if (err.name === 'InvalidCredentialsError') { + logger.debug(`🚫 Invalid credentials for DN: ${userDN}`) + resolve(false) + } else { + logger.error('❌ LDAP authentication error:', err) + reject(err) + } + } else { + logger.debug(`✅ Authentication successful for DN: ${userDN}`) + resolve(true) + } + }) + }) + } + + // 📝 提取用户信息 + extractUserInfo(ldapEntry, username) { + try { + const attributes = ldapEntry.attributes || [] + const userInfo = { username } + + // 创建属性映射 + const attrMap = {} + attributes.forEach(attr => { + const name = attr.type || attr.name + const values = Array.isArray(attr.values) ? attr.values : [attr.values] + attrMap[name] = values.length === 1 ? values[0] : values + }) + + // 根据配置映射用户属性 + const mapping = this.config.userMapping + + userInfo.displayName = attrMap[mapping.displayName] || username + userInfo.email = attrMap[mapping.email] || '' + userInfo.firstName = attrMap[mapping.firstName] || '' + userInfo.lastName = attrMap[mapping.lastName] || '' + + // 如果没有displayName,尝试组合firstName和lastName + if (!userInfo.displayName || userInfo.displayName === username) { + if (userInfo.firstName || userInfo.lastName) { + userInfo.displayName = `${userInfo.firstName || ''} ${userInfo.lastName || ''}`.trim() + } + } + + logger.debug('📋 Extracted user info:', { + username: userInfo.username, + displayName: userInfo.displayName, + email: userInfo.email + }) + + return userInfo + } catch (error) { + logger.error('❌ Error extracting user info:', error) + return { username } + } + } + + // 🔐 主要的登录验证方法 + async authenticateUserCredentials(username, password) { + if (!this.config.enabled) { + throw new Error('LDAP authentication is not enabled') + } + + if (!username || !password) { + throw new Error('Username and password are required') + } + + const client = this.createClient() + + try { + // 1. 使用管理员凭据绑定 + await this.bindClient(client) + + // 2. 搜索用户 + const ldapEntry = await this.searchUser(client, username) + if (!ldapEntry) { + logger.info(`🚫 User not found in LDAP: ${username}`) + return { success: false, message: 'Invalid username or password' } + } + + // 3. 获取用户DN + const userDN = ldapEntry.dn + logger.debug(`👤 Found user DN: ${userDN}`) + + // 4. 验证用户密码 + const isPasswordValid = await this.authenticateUser(userDN, password) + if (!isPasswordValid) { + logger.info(`🚫 Invalid password for user: ${username}`) + return { success: false, message: 'Invalid username or password' } + } + + // 5. 提取用户信息 + const userInfo = this.extractUserInfo(ldapEntry, username) + + // 6. 创建或更新本地用户 + const user = await userService.createOrUpdateUser(userInfo) + + // 7. 记录登录 + await userService.recordUserLogin(user.id) + + // 8. 创建用户会话 + const sessionToken = await userService.createUserSession(user.id) + + logger.info(`✅ LDAP authentication successful for user: ${username}`) + + return { + success: true, + user, + sessionToken, + message: 'Authentication successful' + } + + } catch (error) { + logger.error('❌ LDAP authentication error:', error) + return { + success: false, + message: 'Authentication service unavailable' + } + } finally { + // 确保客户端连接被关闭 + if (client) { + client.unbind((err) => { + if (err) { + logger.debug('Error unbinding LDAP client:', err) + } + }) + } + } + } + + // 🔍 测试LDAP连接 + async testConnection() { + if (!this.config.enabled) { + return { success: false, message: 'LDAP is not enabled' } + } + + const client = this.createClient() + + try { + await this.bindClient(client) + + return { + success: true, + message: 'LDAP connection successful', + server: this.config.server.url, + searchBase: this.config.server.searchBase + } + } catch (error) { + logger.error('❌ LDAP connection test failed:', error) + return { + success: false, + message: `LDAP connection failed: ${error.message}`, + server: this.config.server.url + } + } finally { + if (client) { + client.unbind((err) => { + if (err) { + logger.debug('Error unbinding test LDAP client:', err) + } + }) + } + } + } + + // 📊 获取LDAP配置信息(不包含敏感信息) + getConfigInfo() { + return { + enabled: this.config.enabled, + server: { + url: this.config.server.url, + searchBase: this.config.server.searchBase, + searchFilter: this.config.server.searchFilter, + timeout: this.config.server.timeout + }, + userMapping: this.config.userMapping + } + } +} + +module.exports = new LdapService() \ No newline at end of file diff --git a/src/services/userService.js b/src/services/userService.js new file mode 100644 index 00000000..6c3a9c3a --- /dev/null +++ b/src/services/userService.js @@ -0,0 +1,408 @@ +const redis = require('../models/redis') +const crypto = require('crypto') +const bcrypt = require('bcryptjs') +const logger = require('../utils/logger') +const config = require('../../config/config') + +class UserService { + constructor() { + this.userPrefix = 'user:' + this.usernamePrefix = 'username:' + this.userSessionPrefix = 'user_session:' + } + + // 🔑 生成用户ID + generateUserId() { + return crypto.randomBytes(16).toString('hex') + } + + // 🔑 生成会话Token + generateSessionToken() { + return crypto.randomBytes(32).toString('hex') + } + + // 👤 创建或更新用户 + async createOrUpdateUser(userData) { + try { + const { + username, + email, + displayName, + firstName, + lastName, + role = config.userManagement.defaultUserRole, + isActive = true + } = userData + + // 检查用户是否已存在 + let user = await this.getUserByUsername(username) + const isNewUser = !user + + if (isNewUser) { + const userId = this.generateUserId() + user = { + id: userId, + username, + email, + displayName, + firstName, + lastName, + role, + isActive, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastLoginAt: null, + apiKeyCount: 0, + totalUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + } + } else { + // 更新现有用户信息 + user = { + ...user, + email, + displayName, + firstName, + lastName, + updatedAt: new Date().toISOString() + } + } + + // 保存用户信息 + await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user)) + await redis.set(`${this.usernamePrefix}${username}`, user.id) + + logger.info(`📝 ${isNewUser ? 'Created' : 'Updated'} user: ${username} (${user.id})`) + return user + } catch (error) { + logger.error('❌ Error creating/updating user:', error) + throw error + } + } + + // 👤 通过用户名获取用户 + async getUserByUsername(username) { + try { + const userId = await redis.get(`${this.usernamePrefix}${username}`) + if (!userId) return null + + const userData = await redis.get(`${this.userPrefix}${userId}`) + return userData ? JSON.parse(userData) : null + } catch (error) { + logger.error('❌ Error getting user by username:', error) + throw error + } + } + + // 👤 通过ID获取用户 + async getUserById(userId) { + try { + const userData = await redis.get(`${this.userPrefix}${userId}`) + return userData ? JSON.parse(userData) : null + } catch (error) { + logger.error('❌ Error getting user by ID:', error) + throw error + } + } + + // 📋 获取所有用户列表(管理员功能) + async getAllUsers(options = {}) { + try { + const { page = 1, limit = 20, role, isActive } = options + const pattern = `${this.userPrefix}*` + const keys = await redis.keys(pattern) + + const users = [] + for (const key of keys) { + const userData = await redis.get(key) + if (userData) { + const user = JSON.parse(userData) + + // 应用过滤条件 + if (role && user.role !== role) continue + if (typeof isActive === 'boolean' && user.isActive !== isActive) continue + + users.push(user) + } + } + + // 排序和分页 + users.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + const startIndex = (page - 1) * limit + const endIndex = startIndex + limit + const paginatedUsers = users.slice(startIndex, endIndex) + + return { + users: paginatedUsers, + total: users.length, + page, + limit, + totalPages: Math.ceil(users.length / limit) + } + } catch (error) { + logger.error('❌ Error getting all users:', error) + throw error + } + } + + // 🔄 更新用户状态 + async updateUserStatus(userId, isActive) { + try { + const user = await this.getUserById(userId) + if (!user) { + throw new Error('User not found') + } + + user.isActive = isActive + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + logger.info(`🔄 Updated user status: ${user.username} -> ${isActive ? 'active' : 'disabled'}`) + + // 如果禁用用户,删除所有会话 + if (!isActive) { + await this.invalidateUserSessions(userId) + } + + return user + } catch (error) { + logger.error('❌ Error updating user status:', error) + throw error + } + } + + // 🔄 更新用户角色 + async updateUserRole(userId, role) { + try { + const user = await this.getUserById(userId) + if (!user) { + throw new Error('User not found') + } + + user.role = role + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + logger.info(`🔄 Updated user role: ${user.username} -> ${role}`) + + return user + } catch (error) { + logger.error('❌ Error updating user role:', error) + throw error + } + } + + // 📊 更新用户使用统计 + async updateUserUsage(userId, usage) { + try { + const user = await this.getUserById(userId) + if (!user) return + + const { requests = 0, inputTokens = 0, outputTokens = 0, cost = 0 } = usage + + user.totalUsage.requests += requests + user.totalUsage.inputTokens += inputTokens + user.totalUsage.outputTokens += outputTokens + user.totalUsage.totalCost += cost + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + } catch (error) { + logger.error('❌ Error updating user usage:', error) + } + } + + // 📊 更新用户API Key数量 + async updateUserApiKeyCount(userId, count) { + try { + const user = await this.getUserById(userId) + if (!user) return + + user.apiKeyCount = count + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + } catch (error) { + logger.error('❌ Error updating user API key count:', error) + } + } + + // 📝 记录用户登录 + async recordUserLogin(userId) { + try { + const user = await this.getUserById(userId) + if (!user) return + + user.lastLoginAt = new Date().toISOString() + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + } catch (error) { + logger.error('❌ Error recording user login:', error) + } + } + + // 🎫 创建用户会话 + async createUserSession(userId, sessionData = {}) { + try { + const sessionToken = this.generateSessionToken() + const session = { + token: sessionToken, + userId, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + config.userManagement.userSessionTimeout).toISOString(), + ...sessionData + } + + const ttl = Math.floor(config.userManagement.userSessionTimeout / 1000) + await redis.setex(`${this.userSessionPrefix}${sessionToken}`, ttl, JSON.stringify(session)) + + logger.info(`🎫 Created session for user: ${userId}`) + return sessionToken + } catch (error) { + logger.error('❌ Error creating user session:', error) + throw error + } + } + + // 🎫 验证用户会话 + async validateUserSession(sessionToken) { + try { + const sessionData = await redis.get(`${this.userSessionPrefix}${sessionToken}`) + if (!sessionData) return null + + const session = JSON.parse(sessionData) + + // 检查会话是否过期 + if (new Date() > new Date(session.expiresAt)) { + await this.invalidateUserSession(sessionToken) + return null + } + + // 获取用户信息 + const user = await this.getUserById(session.userId) + if (!user || !user.isActive) { + await this.invalidateUserSession(sessionToken) + return null + } + + return { session, user } + } catch (error) { + logger.error('❌ Error validating user session:', error) + return null + } + } + + // 🚫 使用户会话失效 + async invalidateUserSession(sessionToken) { + try { + await redis.del(`${this.userSessionPrefix}${sessionToken}`) + logger.info(`🚫 Invalidated session: ${sessionToken}`) + } catch (error) { + logger.error('❌ Error invalidating user session:', error) + } + } + + // 🚫 使用户所有会话失效 + async invalidateUserSessions(userId) { + try { + const pattern = `${this.userSessionPrefix}*` + const keys = await redis.keys(pattern) + + for (const key of keys) { + const sessionData = await redis.get(key) + if (sessionData) { + const session = JSON.parse(sessionData) + if (session.userId === userId) { + await redis.del(key) + } + } + } + + logger.info(`🚫 Invalidated all sessions for user: ${userId}`) + } catch (error) { + logger.error('❌ Error invalidating user sessions:', error) + } + } + + // 🗑️ 删除用户(软删除,标记为不活跃) + async deleteUser(userId) { + try { + const user = await this.getUserById(userId) + if (!user) { + throw new Error('User not found') + } + + // 软删除:标记为不活跃并添加删除时间戳 + user.isActive = false + user.deletedAt = new Date().toISOString() + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + + // 删除所有会话 + await this.invalidateUserSessions(userId) + + logger.info(`🗑️ Soft deleted user: ${user.username} (${userId})`) + return user + } catch (error) { + logger.error('❌ Error deleting user:', error) + throw error + } + } + + // 📊 获取用户统计信息 + async getUserStats() { + try { + const pattern = `${this.userPrefix}*` + const keys = await redis.keys(pattern) + + const stats = { + totalUsers: 0, + activeUsers: 0, + adminUsers: 0, + regularUsers: 0, + totalApiKeys: 0, + totalUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + } + + for (const key of keys) { + const userData = await redis.get(key) + if (userData) { + const user = JSON.parse(userData) + stats.totalUsers++ + + if (user.isActive) { + stats.activeUsers++ + } + + if (user.role === 'admin') { + stats.adminUsers++ + } else { + stats.regularUsers++ + } + + stats.totalApiKeys += user.apiKeyCount || 0 + stats.totalUsage.requests += user.totalUsage?.requests || 0 + stats.totalUsage.inputTokens += user.totalUsage?.inputTokens || 0 + stats.totalUsage.outputTokens += user.totalUsage?.outputTokens || 0 + stats.totalUsage.totalCost += user.totalUsage?.totalCost || 0 + } + } + + return stats + } catch (error) { + logger.error('❌ Error getting user stats:', error) + throw error + } + } +} + +module.exports = new UserService() \ No newline at end of file diff --git a/web/admin-spa/src/components/admin/ChangeRoleModal.vue b/web/admin-spa/src/components/admin/ChangeRoleModal.vue new file mode 100644 index 00000000..95a15019 --- /dev/null +++ b/web/admin-spa/src/components/admin/ChangeRoleModal.vue @@ -0,0 +1,208 @@ + + + + + \ No newline at end of file diff --git a/web/admin-spa/src/components/admin/UserUsageStatsModal.vue b/web/admin-spa/src/components/admin/UserUsageStatsModal.vue new file mode 100644 index 00000000..6d52dc2f --- /dev/null +++ b/web/admin-spa/src/components/admin/UserUsageStatsModal.vue @@ -0,0 +1,314 @@ + + + + + \ No newline at end of file diff --git a/web/admin-spa/src/components/layout/MainLayout.vue b/web/admin-spa/src/components/layout/MainLayout.vue index 2c27ecf3..16099b40 100644 --- a/web/admin-spa/src/components/layout/MainLayout.vue +++ b/web/admin-spa/src/components/layout/MainLayout.vue @@ -41,6 +41,7 @@ const tabRouteMap = { dashboard: '/dashboard', apiKeys: '/api-keys', accounts: '/accounts', + userManagement: '/user-management', tutorial: '/tutorial', settings: '/settings' } diff --git a/web/admin-spa/src/components/layout/TabBar.vue b/web/admin-spa/src/components/layout/TabBar.vue index 8a8c953e..9d2e330d 100644 --- a/web/admin-spa/src/components/layout/TabBar.vue +++ b/web/admin-spa/src/components/layout/TabBar.vue @@ -46,6 +46,7 @@ const tabs = [ { key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' }, { key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' }, { key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }, + { key: 'userManagement', name: '用户管理', shortName: '用户', icon: 'fas fa-users' }, { key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' }, { key: 'settings', name: '其他设置', shortName: '设置', icon: 'fas fa-cogs' } ] diff --git a/web/admin-spa/src/components/user/CreateApiKeyModal.vue b/web/admin-spa/src/components/user/CreateApiKeyModal.vue new file mode 100644 index 00000000..5268acb5 --- /dev/null +++ b/web/admin-spa/src/components/user/CreateApiKeyModal.vue @@ -0,0 +1,281 @@ + + + + + \ No newline at end of file diff --git a/web/admin-spa/src/components/user/UserApiKeysManager.vue b/web/admin-spa/src/components/user/UserApiKeysManager.vue new file mode 100644 index 00000000..fb7222e7 --- /dev/null +++ b/web/admin-spa/src/components/user/UserApiKeysManager.vue @@ -0,0 +1,287 @@ + + + + + \ No newline at end of file diff --git a/web/admin-spa/src/components/user/UserUsageStats.vue b/web/admin-spa/src/components/user/UserUsageStats.vue new file mode 100644 index 00000000..c5ecf435 --- /dev/null +++ b/web/admin-spa/src/components/user/UserUsageStats.vue @@ -0,0 +1,276 @@ + + + + + \ No newline at end of file diff --git a/web/admin-spa/src/components/user/ViewApiKeyModal.vue b/web/admin-spa/src/components/user/ViewApiKeyModal.vue new file mode 100644 index 00000000..a51eb4b8 --- /dev/null +++ b/web/admin-spa/src/components/user/ViewApiKeyModal.vue @@ -0,0 +1,248 @@ + + + + + \ No newline at end of file diff --git a/web/admin-spa/src/router/index.js b/web/admin-spa/src/router/index.js index 47680c7d..7616410c 100644 --- a/web/admin-spa/src/router/index.js +++ b/web/admin-spa/src/router/index.js @@ -1,9 +1,13 @@ import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import { useUserStore } from '@/stores/user' import { APP_CONFIG } from '@/config/app' // 路由懒加载 const LoginView = () => import('@/views/LoginView.vue') +const UserLoginView = () => import('@/views/UserLoginView.vue') +const UserDashboardView = () => import('@/views/UserDashboardView.vue') +const UserManagementView = () => import('@/views/UserManagementView.vue') const MainLayout = () => import('@/components/layout/MainLayout.vue') const DashboardView = () => import('@/views/DashboardView.vue') const ApiKeysView = () => import('@/views/ApiKeysView.vue') @@ -35,6 +39,22 @@ const routes = [ component: LoginView, meta: { requiresAuth: false } }, + { + path: '/admin-login', + redirect: '/login' + }, + { + path: '/user-login', + name: 'UserLogin', + component: UserLoginView, + meta: { requiresAuth: false, userAuth: true } + }, + { + path: '/user-dashboard', + name: 'UserDashboard', + component: UserDashboardView, + meta: { requiresUserAuth: true } + }, { path: '/api-stats', name: 'ApiStats', @@ -101,6 +121,18 @@ const routes = [ } ] }, + { + path: '/user-management', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'UserManagement', + component: UserManagementView + } + ] + }, // 捕获所有未匹配的路由 { path: '/:pathMatch(.*)*', @@ -114,15 +146,18 @@ const router = createRouter({ }) // 路由守卫 -router.beforeEach((to, from, next) => { +router.beforeEach(async (to, from, next) => { const authStore = useAuthStore() + const userStore = useUserStore() console.log('路由导航:', { to: to.path, from: from.path, fullPath: to.fullPath, requiresAuth: to.meta.requiresAuth, - isAuthenticated: authStore.isAuthenticated + requiresUserAuth: to.meta.requiresUserAuth, + isAuthenticated: authStore.isAuthenticated, + isUserAuthenticated: userStore.isAuthenticated }) // 防止重定向循环:如果已经在目标路径,直接放行 @@ -130,9 +165,28 @@ router.beforeEach((to, from, next) => { return next() } + // 检查用户认证状态 + if (to.meta.requiresUserAuth) { + if (!userStore.isAuthenticated) { + // 尝试检查本地存储的认证信息 + const isUserLoggedIn = await userStore.checkAuth() + if (!isUserLoggedIn) { + return next('/user-login') + } + } + return next() + } + // API Stats 页面不需要认证,直接放行 if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) { next() + } else if (to.path === '/user-login') { + // 如果已经是用户登录状态,重定向到用户仪表板 + if (userStore.isAuthenticated) { + next('/user-dashboard') + } else { + next() + } } else if (to.meta.requiresAuth && !authStore.isAuthenticated) { next('/login') } else if (to.path === '/login' && authStore.isAuthenticated) { diff --git a/web/admin-spa/src/stores/user.js b/web/admin-spa/src/stores/user.js new file mode 100644 index 00000000..49c2970e --- /dev/null +++ b/web/admin-spa/src/stores/user.js @@ -0,0 +1,185 @@ +import { defineStore } from 'pinia' +import axios from 'axios' + +const API_BASE = '/users' + +export const useUserStore = defineStore('user', { + state: () => ({ + user: null, + isAuthenticated: false, + sessionToken: null, + loading: false + }), + + getters: { + isLoggedIn: (state) => state.isAuthenticated && state.user, + userName: (state) => state.user?.displayName || state.user?.username, + userRole: (state) => state.user?.role + }, + + actions: { + // 🔐 用户登录 + async login(credentials) { + this.loading = true + try { + const response = await axios.post(`${API_BASE}/login`, credentials) + + if (response.data.success) { + this.user = response.data.user + this.sessionToken = response.data.sessionToken + this.isAuthenticated = true + + // 保存到 localStorage + localStorage.setItem('userToken', this.sessionToken) + localStorage.setItem('userData', JSON.stringify(this.user)) + + // 设置 axios 默认头部 + this.setAuthHeader() + + return response.data + } else { + throw new Error(response.data.message || 'Login failed') + } + } catch (error) { + this.clearAuth() + throw error + } finally { + this.loading = false + } + }, + + // 🚪 用户登出 + async logout() { + try { + if (this.sessionToken) { + await axios.post(`${API_BASE}/logout`, {}, { + headers: { 'x-user-token': this.sessionToken } + }) + } + } catch (error) { + console.error('Logout request failed:', error) + } finally { + this.clearAuth() + } + }, + + // 🔄 检查认证状态 + async checkAuth() { + const token = localStorage.getItem('userToken') + const userData = localStorage.getItem('userData') + + if (!token || !userData) { + this.clearAuth() + return false + } + + try { + this.sessionToken = token + this.user = JSON.parse(userData) + this.isAuthenticated = true + this.setAuthHeader() + + // 验证 token 是否仍然有效 + await this.getUserProfile() + return true + } catch (error) { + console.error('Auth check failed:', error) + this.clearAuth() + return false + } + }, + + // 👤 获取用户资料 + async getUserProfile() { + try { + const response = await axios.get(`${API_BASE}/profile`) + + if (response.data.success) { + this.user = response.data.user + localStorage.setItem('userData', JSON.stringify(this.user)) + return response.data.user + } + } catch (error) { + if (error.response?.status === 401) { + this.clearAuth() + } + throw error + } + }, + + // 🔑 获取用户API Keys + async getUserApiKeys() { + try { + const response = await axios.get(`${API_BASE}/api-keys`) + return response.data.success ? response.data.apiKeys : [] + } catch (error) { + console.error('Failed to fetch API keys:', error) + throw error + } + }, + + // 🔑 创建API Key + async createApiKey(keyData) { + try { + const response = await axios.post(`${API_BASE}/api-keys`, keyData) + return response.data + } catch (error) { + console.error('Failed to create API key:', error) + throw error + } + }, + + // 🔄 重新生成API Key + async regenerateApiKey(keyId) { + try { + const response = await axios.post(`${API_BASE}/api-keys/${keyId}/regenerate`) + return response.data + } catch (error) { + console.error('Failed to regenerate API key:', error) + throw error + } + }, + + // 🗑️ 删除API Key + async deleteApiKey(keyId) { + try { + const response = await axios.delete(`${API_BASE}/api-keys/${keyId}`) + return response.data + } catch (error) { + console.error('Failed to delete API key:', error) + throw error + } + }, + + // 📊 获取使用统计 + async getUserUsageStats(params = {}) { + try { + const response = await axios.get(`${API_BASE}/usage-stats`, { params }) + return response.data.success ? response.data.stats : null + } catch (error) { + console.error('Failed to fetch usage stats:', error) + throw error + } + }, + + // 🧹 清除认证信息 + clearAuth() { + this.user = null + this.sessionToken = null + this.isAuthenticated = false + + localStorage.removeItem('userToken') + localStorage.removeItem('userData') + + // 清除 axios 默认头部 + delete axios.defaults.headers.common['x-user-token'] + }, + + // 🔧 设置认证头部 + setAuthHeader() { + if (this.sessionToken) { + axios.defaults.headers.common['x-user-token'] = this.sessionToken + } + } + } +}) \ No newline at end of file diff --git a/web/admin-spa/src/views/UserDashboardView.vue b/web/admin-spa/src/views/UserDashboardView.vue new file mode 100644 index 00000000..d46e1d69 --- /dev/null +++ b/web/admin-spa/src/views/UserDashboardView.vue @@ -0,0 +1,266 @@ + + + + + \ No newline at end of file diff --git a/web/admin-spa/src/views/UserLoginView.vue b/web/admin-spa/src/views/UserLoginView.vue new file mode 100644 index 00000000..85d7751a --- /dev/null +++ b/web/admin-spa/src/views/UserLoginView.vue @@ -0,0 +1,146 @@ + + + + + \ No newline at end of file diff --git a/web/admin-spa/src/views/UserManagementView.vue b/web/admin-spa/src/views/UserManagementView.vue new file mode 100644 index 00000000..7b2bbf91 --- /dev/null +++ b/web/admin-spa/src/views/UserManagementView.vue @@ -0,0 +1,511 @@ + + + + + \ No newline at end of file From f193db926db12bed70fae8dabcd88053d89fac68 Mon Sep 17 00:00:00 2001 From: Feng Yue <2525275@gmail.com> Date: Wed, 13 Aug 2025 13:10:31 +0800 Subject: [PATCH 02/85] fix: lint errors --- .../src/components/admin/ChangeRoleModal.vue | 134 ++++--- .../components/admin/UserUsageStatsModal.vue | 252 +++++++++---- .../src/components/user/CreateApiKeyModal.vue | 161 ++++++--- .../components/user/UserApiKeysManager.vue | 168 ++++++--- .../src/components/user/UserUsageStats.vue | 249 +++++++++---- .../src/components/user/ViewApiKeyModal.vue | 181 ++++++--- web/admin-spa/src/views/UserDashboardView.vue | 177 ++++++--- web/admin-spa/src/views/UserLoginView.vue | 91 +++-- .../src/views/UserManagementView.vue | 342 ++++++++++++------ 9 files changed, 1214 insertions(+), 541 deletions(-) diff --git a/web/admin-spa/src/components/admin/ChangeRoleModal.vue b/web/admin-spa/src/components/admin/ChangeRoleModal.vue index 95a15019..9ee2b878 100644 --- a/web/admin-spa/src/components/admin/ChangeRoleModal.vue +++ b/web/admin-spa/src/components/admin/ChangeRoleModal.vue @@ -1,40 +1,59 @@