From 587012396b7d40fee0193bf90b009e8f5b9d54ad Mon Sep 17 00:00:00 2001 From: shaw Date: Sat, 20 Dec 2025 15:11:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98APIKEY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/handler/admin/setting_handler.go | 40 +++++ backend/internal/middleware/admin_auth.go | 130 ++++++++++++++ backend/internal/model/setting.go | 6 + backend/internal/repository/user_repo.go | 13 ++ backend/internal/server/router.go | 6 +- backend/internal/service/ports/user.go | 1 + backend/internal/service/setting_service.go | 62 +++++++ frontend/src/api/admin/settings.ts | 38 ++++ frontend/src/i18n/locales/en.ts | 22 +++ frontend/src/i18n/locales/zh.ts | 22 +++ frontend/src/views/admin/SettingsView.vue | 166 ++++++++++++++++++ 11 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 backend/internal/middleware/admin_auth.go diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 5fe4dc7e..13bb4835 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -256,3 +256,43 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) { response.Success(c, gin.H{"message": "Test email sent successfully"}) } + +// GetAdminApiKey 获取管理员 API Key 状态 +// GET /api/v1/admin/settings/admin-api-key +func (h *SettingHandler) GetAdminApiKey(c *gin.Context) { + maskedKey, exists, err := h.settingService.GetAdminApiKeyStatus(c.Request.Context()) + if err != nil { + response.InternalError(c, "Failed to get admin API key status: "+err.Error()) + return + } + + response.Success(c, gin.H{ + "exists": exists, + "masked_key": maskedKey, + }) +} + +// RegenerateAdminApiKey 生成/重新生成管理员 API Key +// POST /api/v1/admin/settings/admin-api-key/regenerate +func (h *SettingHandler) RegenerateAdminApiKey(c *gin.Context) { + key, err := h.settingService.GenerateAdminApiKey(c.Request.Context()) + if err != nil { + response.InternalError(c, "Failed to generate admin API key: "+err.Error()) + return + } + + response.Success(c, gin.H{ + "key": key, // 完整 key 只在生成时返回一次 + }) +} + +// DeleteAdminApiKey 删除管理员 API Key +// DELETE /api/v1/admin/settings/admin-api-key +func (h *SettingHandler) DeleteAdminApiKey(c *gin.Context) { + if err := h.settingService.DeleteAdminApiKey(c.Request.Context()); err != nil { + response.InternalError(c, "Failed to delete admin API key: "+err.Error()) + return + } + + response.Success(c, gin.H{"message": "Admin API key deleted"}) +} diff --git a/backend/internal/middleware/admin_auth.go b/backend/internal/middleware/admin_auth.go new file mode 100644 index 00000000..80cc17d4 --- /dev/null +++ b/backend/internal/middleware/admin_auth.go @@ -0,0 +1,130 @@ +package middleware + +import ( + "context" + "crypto/subtle" + "strings" + "sub2api/internal/model" + "sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// AdminAuth 管理员认证中间件 +// 支持两种认证方式(通过不同的 header 区分): +// 1. Admin API Key: x-api-key: +// 2. JWT Token: Authorization: Bearer (需要管理员角色) +func AdminAuth( + authService *service.AuthService, + userRepo interface { + GetByID(ctx context.Context, id int64) (*model.User, error) + GetFirstAdmin(ctx context.Context) (*model.User, error) + }, + settingService *service.SettingService, +) gin.HandlerFunc { + return func(c *gin.Context) { + // 检查 x-api-key header(Admin API Key 认证) + apiKey := c.GetHeader("x-api-key") + if apiKey != "" { + if !validateAdminApiKey(c, apiKey, settingService, userRepo) { + return + } + c.Next() + return + } + + // 检查 Authorization header(JWT 认证) + authHeader := c.GetHeader("Authorization") + if authHeader != "" { + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" { + if !validateJWTForAdmin(c, parts[1], authService, userRepo) { + return + } + c.Next() + return + } + } + + // 无有效认证信息 + AbortWithError(c, 401, "UNAUTHORIZED", "Authorization required") + } +} + +// validateAdminApiKey 验证管理员 API Key +func validateAdminApiKey( + c *gin.Context, + key string, + settingService *service.SettingService, + userRepo interface { + GetFirstAdmin(ctx context.Context) (*model.User, error) + }, +) bool { + storedKey, err := settingService.GetAdminApiKey(c.Request.Context()) + if err != nil { + AbortWithError(c, 500, "INTERNAL_ERROR", "Internal server error") + return false + } + + // 未配置或不匹配,统一返回相同错误(避免信息泄露) + if storedKey == "" || subtle.ConstantTimeCompare([]byte(key), []byte(storedKey)) != 1 { + AbortWithError(c, 401, "INVALID_ADMIN_KEY", "Invalid admin API key") + return false + } + + // 获取真实的管理员用户 + admin, err := userRepo.GetFirstAdmin(c.Request.Context()) + if err != nil { + AbortWithError(c, 500, "INTERNAL_ERROR", "No admin user found") + return false + } + + c.Set(string(ContextKeyUser), admin) + c.Set("auth_method", "admin_api_key") + return true +} + +// validateJWTForAdmin 验证 JWT 并检查管理员权限 +func validateJWTForAdmin( + c *gin.Context, + token string, + authService *service.AuthService, + userRepo interface { + GetByID(ctx context.Context, id int64) (*model.User, error) + }, +) bool { + // 验证 JWT token + claims, err := authService.ValidateToken(token) + if err != nil { + if err == service.ErrTokenExpired { + AbortWithError(c, 401, "TOKEN_EXPIRED", "Token has expired") + return false + } + AbortWithError(c, 401, "INVALID_TOKEN", "Invalid token") + return false + } + + // 从数据库获取用户 + user, err := userRepo.GetByID(c.Request.Context(), claims.UserID) + if err != nil { + AbortWithError(c, 401, "USER_NOT_FOUND", "User not found") + return false + } + + // 检查用户状态 + if !user.IsActive() { + AbortWithError(c, 401, "USER_INACTIVE", "User account is not active") + return false + } + + // 检查管理员权限 + if user.Role != model.RoleAdmin { + AbortWithError(c, 403, "FORBIDDEN", "Admin access required") + return false + } + + c.Set(string(ContextKeyUser), user) + c.Set("auth_method", "jwt") + + return true +} diff --git a/backend/internal/model/setting.go b/backend/internal/model/setting.go index 0551a679..3bfa5068 100644 --- a/backend/internal/model/setting.go +++ b/backend/internal/model/setting.go @@ -46,8 +46,14 @@ const ( // 默认配置 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 SettingKeyDefaultBalance = "default_balance" // 新用户默认余额 + + // 管理员 API Key + SettingKeyAdminApiKey = "admin_api_key" // 全局管理员 API Key(用于外部系统集成) ) +// 管理员 API Key 前缀(与用户 sk- 前缀区分) +const AdminApiKeyPrefix = "admin-" + // SystemSettings 系统设置结构体(用于API响应) type SystemSettings struct { // 注册设置 diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 6867f796..2ec84c3e 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -128,3 +128,16 @@ func (r *UserRepository) RemoveGroupFromAllowedGroups(ctx context.Context, group Update("allowed_groups", gorm.Expr("array_remove(allowed_groups, ?)", groupID)) return result.RowsAffected, result.Error } + +// GetFirstAdmin 获取第一个管理员用户(用于 Admin API Key 认证) +func (r *UserRepository) GetFirstAdmin(ctx context.Context) (*model.User, error) { + var user model.User + err := r.db.WithContext(ctx). + Where("role = ? AND status = ?", model.RoleAdmin, model.StatusActive). + Order("id ASC"). + First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 9787a3cd..360595f0 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -132,7 +132,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep // 管理员接口 admin := v1.Group("/admin") - admin.Use(middleware.JWTAuth(s.Auth, repos.User), middleware.AdminOnly()) + admin.Use(middleware.AdminAuth(s.Auth, repos.User, s.Setting)) { // 仪表盘 dashboard := admin.Group("/dashboard") @@ -236,6 +236,10 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep adminSettings.PUT("", h.Admin.Setting.UpdateSettings) adminSettings.POST("/test-smtp", h.Admin.Setting.TestSmtpConnection) adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail) + // Admin API Key 管理 + adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminApiKey) + adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminApiKey) + adminSettings.DELETE("/admin-api-key", h.Admin.Setting.DeleteAdminApiKey) } // 系统管理 diff --git a/backend/internal/service/ports/user.go b/backend/internal/service/ports/user.go index 44dcec8c..6b2ff21d 100644 --- a/backend/internal/service/ports/user.go +++ b/backend/internal/service/ports/user.go @@ -11,6 +11,7 @@ type UserRepository interface { Create(ctx context.Context, user *model.User) error GetByID(ctx context.Context, id int64) (*model.User, error) GetByEmail(ctx context.Context, email string) (*model.User, error) + GetFirstAdmin(ctx context.Context) (*model.User, error) Update(ctx context.Context, user *model.User) error Delete(ctx context.Context, id int64) error diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index afd6a3a9..f717bbc8 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -2,6 +2,8 @@ package service import ( "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" "strconv" @@ -262,3 +264,63 @@ func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string { } return value } + +// GenerateAdminApiKey 生成新的管理员 API Key +func (s *SettingService) GenerateAdminApiKey(ctx context.Context) (string, error) { + // 生成 32 字节随机数 = 64 位十六进制字符 + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("generate random bytes: %w", err) + } + + key := model.AdminApiKeyPrefix + hex.EncodeToString(bytes) + + // 存储到 settings 表 + if err := s.settingRepo.Set(ctx, model.SettingKeyAdminApiKey, key); err != nil { + return "", fmt.Errorf("save admin api key: %w", err) + } + + return key, nil +} + +// GetAdminApiKeyStatus 获取管理员 API Key 状态 +// 返回脱敏的 key、是否存在、错误 +func (s *SettingService) GetAdminApiKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) { + key, err := s.settingRepo.GetValue(ctx, model.SettingKeyAdminApiKey) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", false, nil + } + return "", false, err + } + if key == "" { + return "", false, nil + } + + // 脱敏:显示前 10 位和后 4 位 + if len(key) > 14 { + maskedKey = key[:10] + "..." + key[len(key)-4:] + } else { + maskedKey = key + } + + return maskedKey, true, nil +} + +// GetAdminApiKey 获取完整的管理员 API Key(仅供内部验证使用) +// 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error +func (s *SettingService) GetAdminApiKey(ctx context.Context) (string, error) { + key, err := s.settingRepo.GetValue(ctx, model.SettingKeyAdminApiKey) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil // 未配置,返回空字符串 + } + return "", err // 数据库错误 + } + return key, nil +} + +// DeleteAdminApiKey 删除管理员 API Key +func (s *SettingService) DeleteAdminApiKey(ctx context.Context) error { + return s.settingRepo.Delete(ctx, model.SettingKeyAdminApiKey) +} diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index b33944d1..9b528dcf 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -99,11 +99,49 @@ export async function sendTestEmail(request: SendTestEmailRequest): Promise<{ me return data; } +/** + * Admin API Key status response + */ +export interface AdminApiKeyStatus { + exists: boolean; + masked_key: string; +} + +/** + * Get admin API key status + * @returns Status indicating if key exists and masked version + */ +export async function getAdminApiKey(): Promise { + const { data } = await apiClient.get('/admin/settings/admin-api-key'); + return data; +} + +/** + * Regenerate admin API key + * @returns The new full API key (only shown once) + */ +export async function regenerateAdminApiKey(): Promise<{ key: string }> { + const { data } = await apiClient.post<{ key: string }>('/admin/settings/admin-api-key/regenerate'); + return data; +} + +/** + * Delete admin API key + * @returns Success message + */ +export async function deleteAdminApiKey(): Promise<{ message: string }> { + const { data } = await apiClient.delete<{ message: string }>('/admin/settings/admin-api-key'); + return data; +} + export const settingsAPI = { getSettings, updateSettings, testSmtpConnection, sendTestEmail, + getAdminApiKey, + regenerateAdminApiKey, + deleteAdminApiKey, }; export default settingsAPI; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 791383a9..597575bd 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -992,6 +992,28 @@ export default { sending: 'Sending...', enterRecipientHint: 'Please enter a recipient email address', }, + adminApiKey: { + title: 'Admin API Key', + description: 'Global API key for external system integration with full admin access', + notConfigured: 'Admin API key not configured', + configured: 'Admin API key is active', + currentKey: 'Current Key', + regenerate: 'Regenerate', + regenerating: 'Regenerating...', + delete: 'Delete', + deleting: 'Deleting...', + create: 'Create Key', + creating: 'Creating...', + regenerateConfirm: 'Are you sure? The current key will be immediately invalidated.', + deleteConfirm: 'Are you sure you want to delete the admin API key? External integrations will stop working.', + keyGenerated: 'New admin API key generated', + keyDeleted: 'Admin API key deleted', + copyKey: 'Copy Key', + keyCopied: 'Key copied to clipboard', + keyWarning: 'This key will only be shown once. Please copy it now.', + securityWarning: 'Warning: This key provides full admin access. Keep it secure.', + usage: 'Usage: Add to request header - x-api-key: ', + }, saveSettings: 'Save Settings', saving: 'Saving...', settingsSaved: 'Settings saved successfully', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index cc3bcdff..43c4c264 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1171,6 +1171,28 @@ export default { sending: '发送中...', enterRecipientHint: '请输入收件人邮箱地址', }, + adminApiKey: { + title: '管理员 API Key', + description: '用于外部系统集成的全局 API Key,拥有完整的管理员权限', + notConfigured: '尚未配置管理员 API Key', + configured: '管理员 API Key 已启用', + currentKey: '当前密钥', + regenerate: '重新生成', + regenerating: '生成中...', + delete: '删除', + deleting: '删除中...', + create: '创建密钥', + creating: '创建中...', + regenerateConfirm: '确定要重新生成吗?当前密钥将立即失效。', + deleteConfirm: '确定要删除管理员 API Key 吗?外部集成将停止工作。', + keyGenerated: '新的管理员 API Key 已生成', + keyDeleted: '管理员 API Key 已删除', + copyKey: '复制密钥', + keyCopied: '密钥已复制到剪贴板', + keyWarning: '此密钥仅显示一次,请立即复制保存。', + securityWarning: '警告:此密钥拥有完整的管理员权限,请妥善保管。', + usage: '使用方法:在请求头中添加 x-api-key: ', + }, saveSettings: '保存设置', saving: '保存中...', settingsSaved: '设置保存成功', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 5a70df61..4bb1b5d1 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -8,6 +8,106 @@
+ +
+
+

{{ t('admin.settings.adminApiKey.title') }}

+

{{ t('admin.settings.adminApiKey.description') }}

+
+
+ +
+
+ + + +

+ {{ t('admin.settings.adminApiKey.securityWarning') }} +

+
+
+ + +
+
+ {{ t('common.loading') }} +
+ + +
+ + {{ t('admin.settings.adminApiKey.notConfigured') }} + + +
+ + +
+
+
+ + + {{ adminApiKeyMasked }} + +
+
+ + +
+
+ + +
+

+ {{ t('admin.settings.adminApiKey.keyWarning') }} +

+
+ + {{ newAdminApiKey }} + + +
+

+ {{ t('admin.settings.adminApiKey.usage') }} +

+
+
+
+
+
@@ -424,6 +524,13 @@ const sendingTestEmail = ref(false); const testEmailAddress = ref(''); const logoError = ref(''); +// Admin API Key 状态 +const adminApiKeyLoading = ref(true); +const adminApiKeyExists = ref(false); +const adminApiKeyMasked = ref(''); +const adminApiKeyOperating = ref(false); +const newAdminApiKey = ref(''); + const form = reactive({ registration_enabled: true, email_verify_enabled: false, @@ -555,7 +662,66 @@ async function sendTestEmail() { } } +// Admin API Key 方法 +async function loadAdminApiKey() { + adminApiKeyLoading.value = true; + try { + const status = await adminAPI.settings.getAdminApiKey(); + adminApiKeyExists.value = status.exists; + adminApiKeyMasked.value = status.masked_key; + } catch (error: any) { + console.error('Failed to load admin API key status:', error); + } finally { + adminApiKeyLoading.value = false; + } +} + +async function createAdminApiKey() { + adminApiKeyOperating.value = true; + try { + const result = await adminAPI.settings.regenerateAdminApiKey(); + newAdminApiKey.value = result.key; + adminApiKeyExists.value = true; + adminApiKeyMasked.value = result.key.substring(0, 10) + '...' + result.key.slice(-4); + appStore.showSuccess(t('admin.settings.adminApiKey.keyGenerated')); + } catch (error: any) { + appStore.showError(error.message || t('common.error')); + } finally { + adminApiKeyOperating.value = false; + } +} + +async function regenerateAdminApiKey() { + if (!confirm(t('admin.settings.adminApiKey.regenerateConfirm'))) return; + await createAdminApiKey(); +} + +async function deleteAdminApiKey() { + if (!confirm(t('admin.settings.adminApiKey.deleteConfirm'))) return; + adminApiKeyOperating.value = true; + try { + await adminAPI.settings.deleteAdminApiKey(); + adminApiKeyExists.value = false; + adminApiKeyMasked.value = ''; + newAdminApiKey.value = ''; + appStore.showSuccess(t('admin.settings.adminApiKey.keyDeleted')); + } catch (error: any) { + appStore.showError(error.message || t('common.error')); + } finally { + adminApiKeyOperating.value = false; + } +} + +function copyNewKey() { + navigator.clipboard.writeText(newAdminApiKey.value).then(() => { + appStore.showSuccess(t('admin.settings.adminApiKey.keyCopied')); + }).catch(() => { + appStore.showError(t('common.copyFailed')); + }); +} + onMounted(() => { loadSettings(); + loadAdminApiKey(); });