diff --git a/.env.example b/.env.example index b69ee64e..62f7fcfb 100644 --- a/.env.example +++ b/.env.example @@ -96,4 +96,5 @@ LDAP_USER_ATTR_LAST_NAME=sn USER_MANAGEMENT_ENABLED=false DEFAULT_USER_ROLE=user USER_SESSION_TIMEOUT=86400000 -MAX_API_KEYS_PER_USER=5 +MAX_API_KEYS_PER_USER=1 +ALLOW_USER_DELETE_API_KEYS=false diff --git a/config/config.example.js b/config/config.example.js index 5b8786b6..433ecd1f 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -175,7 +175,8 @@ const config = { 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 + maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 1, + allowUserDeleteApiKeys: process.env.ALLOW_USER_DELETE_API_KEYS === 'true' // 默认不允许用户删除自己的API Keys }, // 📢 Webhook通知配置 diff --git a/src/routes/admin.js b/src/routes/admin.js index 60381f2d..8c824eab 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -24,6 +24,51 @@ const ProxyHelper = require('../utils/proxyHelper') const router = express.Router() +// 👥 用户管理 + +// 获取所有用户列表(用于API Key分配) +router.get('/users', authenticateAdmin, async (req, res) => { + try { + const userService = require('../services/userService') + const result = await userService.getAllUsers({ isActive: true, limit: 1000 }) // Get all active users + + // Extract users array from the paginated result + const allUsers = result.users || [] + + // Map to the format needed for the dropdown + const activeUsers = allUsers.map((user) => ({ + id: user.id, + username: user.username, + displayName: user.displayName || user.username, + email: user.email, + role: user.role + })) + + // 添加Admin选项作为第一个 + const usersWithAdmin = [ + { + id: 'admin', + username: 'admin', + displayName: 'Admin', + email: '', + role: 'admin' + }, + ...activeUsers + ] + + return res.json({ + success: true, + data: usersWithAdmin + }) + } catch (error) { + logger.error('❌ Failed to get users list:', error) + return res.status(500).json({ + error: 'Failed to get users list', + message: error.message + }) + } +}) + // 🔑 API Keys 管理 // 调试:获取API Key费用详情 @@ -63,6 +108,9 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { const { timeRange = 'all' } = req.query // all, 7days, monthly const apiKeys = await apiKeyService.getAllApiKeys() + // 获取用户服务来补充owner信息 + const userService = require('../services/userService') + // 根据时间范围计算查询模式 const now = new Date() const searchPatterns = [] @@ -313,6 +361,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { } } + // 为每个API Key添加owner的displayName + for (const apiKey of apiKeys) { + // 如果API Key有关联的用户ID,获取用户信息 + if (apiKey.userId) { + try { + const user = await userService.getUserById(apiKey.userId, false) + if (user) { + apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User' + } else { + apiKey.ownerDisplayName = 'Unknown User' + } + } catch (error) { + logger.debug(`无法获取用户 ${apiKey.userId} 的信息:`, error) + apiKey.ownerDisplayName = 'Unknown User' + } + } else { + // 如果没有userId,使用createdBy字段或默认为Admin + apiKey.ownerDisplayName = + apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin' + } + } + return res.json({ success: true, data: apiKeys }) } catch (error) { logger.error('❌ Failed to get API keys:', error) @@ -803,6 +873,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params const { + name, // 添加名称字段 tokenLimit, concurrencyLimit, rateLimitWindow, @@ -822,12 +893,25 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { expiresAt, dailyCostLimit, weeklyOpusCostLimit, - tags + tags, + ownerId // 新增:所有者ID字段 } = req.body // 只允许更新指定字段 const updates = {} + // 处理名称字段 + if (name !== undefined && name !== null && name !== '') { + const trimmedName = name.toString().trim() + if (trimmedName.length === 0) { + return res.status(400).json({ error: 'API Key name cannot be empty' }) + } + if (trimmedName.length > 100) { + return res.status(400).json({ error: 'API Key name must be less than 100 characters' }) + } + updates.name = trimmedName + } + if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') { if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) { return res.status(400).json({ error: 'Token limit must be a non-negative integer' }) @@ -992,6 +1076,45 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.isActive = isActive } + // 处理所有者变更 + if (ownerId !== undefined) { + const userService = require('../services/userService') + + if (ownerId === 'admin') { + // 分配给Admin + updates.userId = '' + updates.userUsername = '' + updates.createdBy = 'admin' + } else if (ownerId) { + // 分配给用户 + try { + const user = await userService.getUserById(ownerId, false) + if (!user) { + return res.status(400).json({ error: 'Invalid owner: User not found' }) + } + if (!user.isActive) { + return res.status(400).json({ error: 'Cannot assign to inactive user' }) + } + + // 设置新的所有者信息 + updates.userId = ownerId + updates.userUsername = user.username + updates.createdBy = user.username + + // 管理员重新分配时,不检查用户的API Key数量限制 + logger.info(`🔄 Admin reassigning API key ${keyId} to user ${user.username}`) + } catch (error) { + logger.error('Error fetching user for owner reassignment:', error) + return res.status(400).json({ error: 'Invalid owner ID' }) + } + } else { + // 清空所有者(分配给Admin) + updates.userId = '' + updates.userUsername = '' + updates.createdBy = 'admin' + } + } + await apiKeyService.updateApiKey(keyId, updates) logger.success(`📝 Admin updated API key: ${keyId}`) diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 653e3c9e..f4f995c1 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -208,7 +208,8 @@ router.get('/profile', authenticateUser, async (req, res) => { totalUsage: user.totalUsage }, config: { - maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser + maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser, + allowUserDeleteApiKeys: config.userManagement.allowUserDeleteApiKeys } }) } catch (error) { @@ -352,6 +353,15 @@ router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => { try { const { keyId } = req.params + // 检查是否允许用户删除自己的API Keys + if (!config.userManagement.allowUserDeleteApiKeys) { + return res.status(403).json({ + error: 'Operation not allowed', + message: + 'Users are not allowed to delete their own API keys. Please contact an administrator.' + }) + } + // 检查API Key是否属于当前用户 const existingKey = await apiKeyService.getApiKeyById(keyId) if (!existingKey || existingKey.userId !== req.user.id) { diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index e2546656..60e0e2d2 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -368,7 +368,10 @@ class ApiKeyService { 'allowedClients', 'dailyCostLimit', 'weeklyOpusCostLimit', - 'tags' + 'tags', + 'userId', // 新增:用户ID(所有者变更) + 'userUsername', // 新增:用户名(所有者变更) + 'createdBy' // 新增:创建者(所有者变更) ] const updatedData = { ...keyData } diff --git a/src/services/ldapService.js b/src/services/ldapService.js index 75b4e704..86fdb88d 100644 --- a/src/services/ldapService.js +++ b/src/services/ldapService.js @@ -97,6 +97,38 @@ class LdapService { return null } + // 🌐 从DN中提取域名,用于Windows AD UPN格式认证 + extractDomainFromDN(dnString) { + try { + if (!dnString || typeof dnString !== 'string') { + return null + } + + // 提取所有DC组件:DC=test,DC=demo,DC=com + const dcMatches = dnString.match(/DC=([^,]+)/gi) + if (!dcMatches || dcMatches.length === 0) { + return null + } + + // 提取DC值并连接成域名 + const domainParts = dcMatches.map((match) => { + const value = match.replace(/DC=/i, '').trim() + return value + }) + + if (domainParts.length > 0) { + const domain = domainParts.join('.') + logger.debug(`🌐 从DN提取域名: ${domain}`) + return domain + } + + return null + } catch (error) { + logger.debug('⚠️ 域名提取失败:', error.message) + return null + } + } + // 🔗 创建LDAP客户端连接 createClient() { try { @@ -336,6 +368,79 @@ class LdapService { }) } + // 🔐 Windows AD兼容认证 - 在DN认证失败时尝试多种格式 + async tryWindowsADAuthentication(username, password) { + if (!username || !password) { + return false + } + + // 从searchBase提取域名 + const domain = this.extractDomainFromDN(this.config.server.searchBase) + + const adFormats = [] + + if (domain) { + // UPN格式(Windows AD标准) + adFormats.push(`${username}@${domain}`) + + // 如果域名有多个部分,也尝试简化版本 + const domainParts = domain.split('.') + if (domainParts.length > 1) { + adFormats.push(`${username}@${domainParts.slice(-2).join('.')}`) // 只取后两部分 + } + + // 域\用户名格式 + const firstDomainPart = domainParts[0] + if (firstDomainPart) { + adFormats.push(`${firstDomainPart}\\${username}`) + adFormats.push(`${firstDomainPart.toUpperCase()}\\${username}`) + } + } + + // 纯用户名(最后尝试) + adFormats.push(username) + + logger.info(`🔄 尝试 ${adFormats.length} 种Windows AD认证格式...`) + + for (const format of adFormats) { + try { + logger.info(`🔍 尝试格式: ${format}`) + const result = await this.tryDirectBind(format, password) + if (result) { + logger.info(`✅ Windows AD认证成功: ${format}`) + return true + } + logger.debug(`❌ 认证失败: ${format}`) + } catch (error) { + logger.debug(`认证异常 ${format}:`, error.message) + } + } + + logger.info(`🚫 所有Windows AD格式认证都失败了`) + return false + } + + // 🔐 直接尝试绑定认证的辅助方法 + async tryDirectBind(identifier, password) { + return new Promise((resolve, reject) => { + const authClient = this.createClient() + + authClient.bind(identifier, password, (err) => { + authClient.unbind() + + if (err) { + if (err.name === 'InvalidCredentialsError') { + resolve(false) + } else { + reject(err) + } + } else { + resolve(true) + } + }) + }) + } + // 📝 提取用户信息 extractUserInfo(ldapEntry, username) { try { @@ -478,10 +583,32 @@ class LdapService { return { success: false, message: 'Authentication service error' } } - // 4. 验证用户密码 - const isPasswordValid = await this.authenticateUser(userDN, password) + // 4. 验证用户密码 - 支持传统LDAP和Windows AD + let isPasswordValid = false + + // 首先尝试传统的DN认证(保持原有LDAP逻辑) + try { + isPasswordValid = await this.authenticateUser(userDN, password) + if (isPasswordValid) { + logger.info(`✅ DN authentication successful for user: ${sanitizedUsername}`) + } + } catch (error) { + logger.debug( + `DN authentication failed for user: ${sanitizedUsername}, error: ${error.message}` + ) + } + + // 如果DN认证失败,尝试Windows AD多格式认证 if (!isPasswordValid) { - logger.info(`🚫 Invalid password for user: ${sanitizedUsername}`) + logger.debug(`🔄 Trying Windows AD authentication formats for user: ${sanitizedUsername}`) + isPasswordValid = await this.tryWindowsADAuthentication(sanitizedUsername, password) + if (isPasswordValid) { + logger.info(`✅ Windows AD authentication successful for user: ${sanitizedUsername}`) + } + } + + if (!isPasswordValid) { + logger.info(`🚫 All authentication methods failed for user: ${sanitizedUsername}`) return { success: false, message: 'Invalid username or password' } } diff --git a/src/services/userService.js b/src/services/userService.js index 601d6419..00f0665f 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -75,6 +75,11 @@ class UserService { await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user)) await redis.set(`${this.usernamePrefix}${username}`, user.id) + // 如果是新用户,尝试转移匹配的API Keys + if (isNewUser) { + await this.transferMatchingApiKeys(user) + } + logger.info(`📝 ${isNewUser ? 'Created' : 'Updated'} user: ${username} (${user.id})`) return user } catch (error) { @@ -509,6 +514,80 @@ class UserService { throw error } } + + // 🔄 转移匹配的API Keys给新用户 + async transferMatchingApiKeys(user) { + try { + const apiKeyService = require('./apiKeyService') + const { displayName, username, email } = user + + // 获取所有API Keys + const allApiKeys = await apiKeyService.getAllApiKeys() + + // 找到没有用户ID的API Keys(即由Admin创建的) + const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '') + + if (unownedApiKeys.length === 0) { + logger.debug(`📝 No unowned API keys found for potential transfer to user: ${username}`) + return + } + + // 构建匹配字符串数组(只考虑displayName、username、email,去除空值和重复值) + const matchStrings = new Set() + if (displayName) { + matchStrings.add(displayName.toLowerCase().trim()) + } + if (username) { + matchStrings.add(username.toLowerCase().trim()) + } + if (email) { + matchStrings.add(email.toLowerCase().trim()) + } + + const matchingKeys = [] + + // 查找名称匹配的API Keys(只进行完全匹配) + for (const apiKey of unownedApiKeys) { + const keyName = apiKey.name ? apiKey.name.toLowerCase().trim() : '' + + // 检查API Key名称是否与用户信息完全匹配 + for (const matchString of matchStrings) { + if (keyName === matchString) { + matchingKeys.push(apiKey) + break // 找到匹配后跳出内层循环 + } + } + } + + // 转移匹配的API Keys + let transferredCount = 0 + for (const apiKey of matchingKeys) { + try { + await apiKeyService.updateApiKey(apiKey.id, { + userId: user.id, + userUsername: user.username, + createdBy: user.username + }) + + transferredCount++ + logger.info(`🔄 Transferred API key "${apiKey.name}" (${apiKey.id}) to user: ${username}`) + } catch (error) { + logger.error(`❌ Failed to transfer API key ${apiKey.id} to user ${username}:`, error) + } + } + + if (transferredCount > 0) { + logger.success( + `🎉 Successfully transferred ${transferredCount} API key(s) to new user: ${username} (${displayName})` + ) + } else if (matchingKeys.length === 0) { + logger.debug(`📝 No matching API keys found for user: ${username} (${displayName})`) + } + } catch (error) { + logger.error('❌ Error transferring matching API keys:', error) + // Don't throw error to prevent blocking user creation + } + } } module.exports = new UserService() diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index f74b25f8..c84aeb37 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -33,12 +33,36 @@ >名称 -

名称不可修改

+

+ 用于识别此 API Key 的用途 +

+ + + +
+ + +

+ 分配此 API Key 给指定用户或管理员,管理员分配时不受用户 API Key 数量限制 +

@@ -666,6 +690,9 @@ const localAccounts = ref({ // 支持的客户端列表 const supportedClients = ref([]) +// 可用用户列表 +const availableUsers = ref([]) + // 标签相关 const newTag = ref('') const availableTags = ref([]) @@ -696,7 +723,8 @@ const form = reactive({ enableClientRestriction: false, allowedClients: [], tags: [], - isActive: true + isActive: true, + ownerId: '' // 新增:所有者ID }) // 添加限制的模型 @@ -774,6 +802,7 @@ const updateApiKey = async () => { try { // 准备提交的数据 const data = { + name: form.name, // 添加名称字段 tokenLimit: 0, // 清除历史token限制 rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null @@ -856,6 +885,11 @@ const updateApiKey = async () => { // 活跃状态 data.isActive = form.isActive + // 所有者 + if (form.ownerId !== undefined) { + data.ownerId = form.ownerId + } + const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data) if (result.success) { @@ -947,11 +981,45 @@ const refreshAccounts = async () => { } } +// 加载用户列表 +const loadUsers = async () => { + try { + const response = await apiClient.get('/admin/users') + if (response.success) { + availableUsers.value = response.data || [] + } + } catch (error) { + console.error('Failed to load users:', error) + availableUsers.value = [ + { + id: 'admin', + username: 'admin', + displayName: 'Admin', + email: '', + role: 'admin' + } + ] + } +} + // 初始化表单数据 onMounted(async () => { - // 加载支持的客户端和已存在的标签 - supportedClients.value = await clientsStore.loadSupportedClients() - availableTags.value = await apiKeysStore.fetchTags() + try { + // 并行加载所有需要的数据 + const [clients, tags] = await Promise.all([ + clientsStore.loadSupportedClients(), + apiKeysStore.fetchTags(), + loadUsers() + ]) + + supportedClients.value = clients || [] + availableTags.value = tags || [] + } catch (error) { + console.error('Error loading initial data:', error) + // Fallback to empty arrays if loading fails + supportedClients.value = [] + availableTags.value = [] + } // 初始化账号数据 if (props.accounts) { @@ -1001,6 +1069,9 @@ onMounted(async () => { form.enableClientRestriction = props.apiKey.enableClientRestriction || false // 初始化活跃状态,默认为 true form.isActive = props.apiKey.isActive !== undefined ? props.apiKey.isActive : true + + // 初始化所有者 + form.ownerId = props.apiKey.userId || 'admin' }) diff --git a/web/admin-spa/src/components/user/UserApiKeysManager.vue b/web/admin-spa/src/components/user/UserApiKeysManager.vue index 8100cd88..092aa73c 100644 --- a/web/admin-spa/src/components/user/UserApiKeysManager.vue +++ b/web/admin-spa/src/components/user/UserApiKeysManager.vue @@ -159,7 +159,11 @@