diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index df6659d689..7a88fa3554 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -179,7 +179,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService) - adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService) + adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService) groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) @@ -216,7 +216,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { registry := payment.ProvideRegistry() defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey) paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService) - settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService) + settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService) opsHandler := admin.NewOpsHandler(opsService) updateCache := repository.NewUpdateCache(redisClient) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) @@ -298,7 +298,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) auditLogMiddleware := middleware.NewAuditLogMiddleware(auditLogService) - stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService) + stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService, settingService) engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient) httpServer := server.ProvideHTTPServer(configConfig, engine) opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig) diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index 23b20f4e91..6c05d7bf5e 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -16,7 +16,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { router := gin.New() adminSvc := newStubAdminService() - userHandler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) + userHandler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil) groupHandler := NewGroupHandler(adminSvc, nil, nil) proxyHandler := NewProxyHandler(adminSvc) redeemHandler := NewRedeemHandler(adminSvc, nil) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 296b27044a..d663cd61b3 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -59,6 +59,8 @@ type SettingHandler struct { paymentService *service.PaymentService userAttributeService *service.UserAttributeService notificationEmailService *service.NotificationEmailService + totpService *service.TotpService + userService *service.UserService } // NewSettingHandler 创建系统设置处理器 @@ -80,6 +82,15 @@ func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *s h.notificationEmailService = notificationEmailService } +// SetStepUpDeps attaches the services backing the step-up switch preconditions +// (enable requires the acting admin to have TOTP enabled; disable is itself a +// step-up gated operation), without changing the constructor signature used by +// existing unit tests. +func (h *SettingHandler) SetStepUpDeps(totpService *service.TotpService, userService *service.UserService) { + h.totpService = totpService + h.userService = userService +} + // GetSettings 获取所有系统设置 // GET /api/v1/admin/settings func (h *SettingHandler) GetSettings(c *gin.Context) { @@ -124,6 +135,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { TotpEnabled: settings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), SessionBindingEnabled: settings.SessionBindingEnabled, + StepUpEnabled: settings.StepUpEnabled, AuditLogRetentionDays: settings.AuditLogRetentionDays, LoginAgreementEnabled: settings.LoginAgreementEnabled, LoginAgreementMode: settings.LoginAgreementMode, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index d94ff13c3c..7d3a420ff4 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -56,6 +56,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.TotpEnabled != after.TotpEnabled { changed = append(changed, "totp_enabled") } + if before.SessionBindingEnabled != after.SessionBindingEnabled { + changed = append(changed, "session_binding_enabled") + } + if before.StepUpEnabled != after.StepUpEnabled { + changed = append(changed, "step_up_enabled") + } if before.LoginAgreementEnabled != after.LoginAgreementEnabled { changed = append(changed, "login_agreement_enabled") } diff --git a/backend/internal/handler/admin/setting_handler_stepup_switch_test.go b/backend/internal/handler/admin/setting_handler_stepup_switch_test.go new file mode 100644 index 0000000000..22f7ebf469 --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_stepup_switch_test.go @@ -0,0 +1,157 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// step-up 开关转换的门控测试。 +// 测试环境不注入认证上下文/userService,因此一旦触发校验会以 401/403/500 中止; +// 借此区分「触发了转换校验」与「直接放行到常规保存(200)」。 + +func newStepUpSwitchTestHandler(t *testing.T, stored map[string]string) (*SettingHandler, *settingHandlerRepoStub) { + t.Helper() + gin.SetMode(gin.TestMode) + repo := &settingHandlerRepoStub{values: stored} + svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) + return NewSettingHandler(svc, nil, nil, nil, nil, nil, nil), repo +} + +func doUpdateSettings(t *testing.T, h *SettingHandler, body map[string]any, prepare func(c *gin.Context)) *httptest.ResponseRecorder { + t.Helper() + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + if prepare != nil { + prepare(c) + } + + h.UpdateSettings(c) + return rec +} + +// 开启开关(false→true):无认证上下文时拒绝,且带专用错误标记。 +func TestUpdateSettingsEnableStepUpRejectsWithoutSession(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{}) + + rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, nil) + + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_ENABLE_REQUIRES_TOTP") + require.NotEqual(t, "true", repo.values[service.SettingKeyStepUpEnabled]) +} + +// 开启开关:admin API key(机器凭证)一律拒绝,reason 与门控保持一致便于前端分流。 +func TestUpdateSettingsEnableStepUpRejectsAdminAPIKey(t *testing.T) { + h, _ := newStepUpSwitchTestHandler(t, map[string]string{}) + + rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, func(c *gin.Context) { + c.Set("auth_method", service.AuditAuthMethodAdminAPIKey) + }) + + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN") +} + +// 开启开关:有认证会话但 userService 未注入时 fail-closed(500),不得放行。 +func TestUpdateSettingsEnableStepUpFailsClosedWithoutUserService(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{}) + + rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, func(c *gin.Context) { + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1}) + }) + + require.Equal(t, http.StatusInternalServerError, rec.Code) + require.NotEqual(t, "true", repo.values[service.SettingKeyStepUpEnabled]) +} + +// 关闭开关(true→false)本身是敏感操作:无认证上下文时被 step-up 门控以 401 拦截。 +func TestUpdateSettingsDisableStepUpRequiresStepUp(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyStepUpEnabled: "true", + }) + + rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, nil) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled]) +} + +// 关闭开关:admin API key 被 step-up 门控以 403 拦截。 +func TestUpdateSettingsDisableStepUpRejectsAdminAPIKey(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyStepUpEnabled: "true", + }) + + rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, func(c *gin.Context) { + c.Set("auth_method", service.AuditAuthMethodAdminAPIKey) + }) + + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN") + require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled]) +} + +// 无状态转换(false→false):不触发任何转换校验,常规保存成功且默认持久化为 false。 +func TestUpdateSettingsStepUpNoTransitionSkipsGate(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{}) + + rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": false}, nil) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "false", repo.values[service.SettingKeyStepUpEnabled]) + // 会话 IP/UA 绑定默认关闭:未显式提交时持久化 false。 + require.Equal(t, "false", repo.values[service.SettingKeySessionBindingEnabled]) +} + +// 保持开启(true→true):不触发转换校验,常规保存不被打断。 +func TestUpdateSettingsStepUpKeepEnabledSkipsGate(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyStepUpEnabled: "true", + }) + + rec := doUpdateSettings(t, h, map[string]any{"step_up_enabled": true}, nil) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled]) +} + +// 省略字段=保持现值:不含 step_up_enabled/session_binding_enabled 的旧客户端全量保存 +// 不得把已开启的安全开关静默重置,也不触发任何转换门控。 +func TestUpdateSettingsOmittedSecuritySwitchesKeepStoredValues(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyStepUpEnabled: "true", + service.SettingKeySessionBindingEnabled: "true", + }) + + rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "true", repo.values[service.SettingKeyStepUpEnabled]) + require.Equal(t, "true", repo.values[service.SettingKeySessionBindingEnabled]) +} + +// 省略字段在开关本就关闭时同样保持关闭(默认值路径)。 +func TestUpdateSettingsOmittedSecuritySwitchesKeepDisabled(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{}) + + rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "false", repo.values[service.SettingKeyStepUpEnabled]) + require.Equal(t, "false", repo.values[service.SettingKeySessionBindingEnabled]) +} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 707af4a666..1e11898e05 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -11,6 +11,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -27,7 +28,8 @@ type UpdateSettingsRequest struct { FrontendURL string `json:"frontend_url"` InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 - SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 + SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值) + StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值) AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 LoginAgreementEnabled bool `json:"login_agreement_enabled"` LoginAgreementMode string `json:"login_agreement_mode"` @@ -335,6 +337,41 @@ type UpdateSettingsRequest struct { // UpdateSettings 更新系统设置 // PUT /api/v1/admin/settings +// ensureActorTotpForStepUp 校验当前操作者具备开启 step-up 门控的条件: +// 必须是真人管理员会话(admin API key 无法完成 TOTP step-up,拒绝)且本人已启用 TOTP。 +// 校验失败时写入错误响应并返回 false。 +func (h *SettingHandler) ensureActorTotpForStepUp(c *gin.Context) bool { + if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey { + response.ErrorWithDetails(c, http.StatusForbidden, + "Admin API key cannot enable step-up verification; use an admin session with TOTP enabled", + "STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil) + return false + } + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok || subject.UserID <= 0 { + response.ErrorWithDetails(c, http.StatusForbidden, + "Enabling step-up verification requires an authenticated admin session", + "STEP_UP_ENABLE_REQUIRES_TOTP", nil) + return false + } + if h.userService == nil { + response.InternalError(c, "Step-up precondition check unavailable") + return false + } + user, err := h.userService.GetByID(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return false + } + if !user.TotpEnabled { + response.ErrorWithDetails(c, http.StatusBadRequest, + "Enable two-factor authentication (TOTP) for your account before turning on step-up verification", + "STEP_UP_ENABLE_REQUIRES_TOTP", nil) + return false + } + return true +} + func (h *SettingHandler) UpdateSettings(c *gin.Context) { var req UpdateSettingsRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -353,6 +390,34 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { return } + // 两个安全开关的请求字段为指针:省略字段=保持现值,避免旧客户端/脚本 + // 用不含新字段的全量 payload 保存设置时把安全开关静默重置。 + sessionBindingEnabled := previousSettings.SessionBindingEnabled + if req.SessionBindingEnabled != nil { + sessionBindingEnabled = *req.SessionBindingEnabled + } + stepUpEnabled := previousSettings.StepUpEnabled + if req.StepUpEnabled != nil { + stepUpEnabled = *req.StepUpEnabled + } + + // 开启敏感操作 step-up 门控属自锁风险操作:仅允许本人已启用 TOTP 的管理员会话开启, + // 否则开启后操作者立即被挡在所有敏感操作之外。仅在 false→true 的开启瞬间校验, + // 保持开启状态的常规设置保存不受影响。 + if stepUpEnabled && !previousSettings.StepUpEnabled { + if !h.ensureActorTotpForStepUp(c) { + return + } + } + // 关闭 step-up 门控本身就是敏感操作:防止拿到管理员会话的攻击者先关闸再执行导出/备份。 + // previousSettings 已证实开关处于开启状态,使用无条件门控变体, + // 避免门控内部二次读取开关时因存储故障 fail-open(前端捕获 STEP_UP_REQUIRED 弹码重试)。 + if !stepUpEnabled && previousSettings.StepUpEnabled { + if !middleware.EnforceStepUpAlways(c, h.totpService, h.userService) { + return + } + } + // 验证参数 if req.DefaultConcurrency < 1 { req.DefaultConcurrency = 1 @@ -1181,7 +1246,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { FrontendURL: req.FrontendURL, InvitationCodeEnabled: req.InvitationCodeEnabled, TotpEnabled: req.TotpEnabled, - SessionBindingEnabled: req.SessionBindingEnabled, + SessionBindingEnabled: sessionBindingEnabled, + StepUpEnabled: stepUpEnabled, AuditLogRetentionDays: req.AuditLogRetentionDays, LoginAgreementEnabled: req.LoginAgreementEnabled, LoginAgreementMode: loginAgreementMode, @@ -1710,6 +1776,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { TotpEnabled: updatedSettings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), SessionBindingEnabled: updatedSettings.SessionBindingEnabled, + StepUpEnabled: updatedSettings.StepUpEnabled, AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays, LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled, LoginAgreementMode: updatedSettings.LoginAgreementMode, diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index cdcc762a81..651dfa714f 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -33,6 +33,7 @@ type UserHandler struct { billingCache service.BillingCache // T17/T18 缓存失效(PUT/POST 路径) totpService *service.TotpService // 角色提升为管理员的 step-up 门控 userService *service.UserService + settingService *service.SettingService // step-up 功能开关 } // NewUserHandler creates a new admin user handler @@ -43,6 +44,7 @@ func NewUserHandler( billingCache service.BillingCache, totpService *service.TotpService, userService *service.UserService, + settingService *service.SettingService, ) *UserHandler { return &UserHandler{ adminService: adminService, @@ -51,6 +53,7 @@ func NewUserHandler( billingCache: billingCache, totpService: totpService, userService: userService, + settingService: settingService, } } @@ -275,7 +278,7 @@ func (h *UserHandler) Create(c *gin.Context) { // 创建管理员账号属权限敏感操作:需最近完成 step-up 2FA 验证。 if req.Role == service.RoleAdmin { - if !middleware.EnforceStepUp(c, h.totpService, h.userService) { + if !middleware.EnforceStepUp(c, h.totpService, h.userService, h.settingService) { return } } @@ -331,7 +334,7 @@ func (h *UserHandler) Update(c *gin.Context) { return } if target.Role != service.RoleAdmin { - if !middleware.EnforceStepUp(c, h.totpService, h.userService) { + if !middleware.EnforceStepUp(c, h.totpService, h.userService, h.settingService) { return } } diff --git a/backend/internal/handler/admin/user_handler_activity_test.go b/backend/internal/handler/admin/user_handler_activity_test.go index 74a34de7c2..f8d7d14f25 100644 --- a/backend/internal/handler/admin/user_handler_activity_test.go +++ b/backend/internal/handler/admin/user_handler_activity_test.go @@ -35,7 +35,7 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) { UpdatedAt: lastLoginAt, }, } - handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) + handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil) recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) @@ -89,7 +89,7 @@ func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) { UpdatedAt: lastLoginAt, }, } - handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) + handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil) recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) diff --git a/backend/internal/handler/admin/user_handler_batch_limits_test.go b/backend/internal/handler/admin/user_handler_batch_limits_test.go index 08e5951aed..06ee942f39 100644 --- a/backend/internal/handler/admin/user_handler_batch_limits_test.go +++ b/backend/internal/handler/admin/user_handler_batch_limits_test.go @@ -44,7 +44,7 @@ func (s *batchLimitsAdminServiceStub) BatchUpdateLimits(_ context.Context, userI func setupBatchLimitsRouter(serviceStub service.AdminService) *gin.Engine { gin.SetMode(gin.TestMode) router := gin.New() - handler := NewUserHandler(serviceStub, nil, nil, nil, nil, nil) + handler := NewUserHandler(serviceStub, nil, nil, nil, nil, nil, nil) router.POST("/api/v1/admin/users/batch-limits", handler.BatchUpdateLimits) return router } diff --git a/backend/internal/handler/admin/user_handler_get_deleted_test.go b/backend/internal/handler/admin/user_handler_get_deleted_test.go index c915a88f8f..6891a8a8e2 100644 --- a/backend/internal/handler/admin/user_handler_get_deleted_test.go +++ b/backend/internal/handler/admin/user_handler_get_deleted_test.go @@ -26,7 +26,7 @@ func (s *getByIDAdminStub) GetUserIncludeDeleted(_ context.Context, id int64) (* func setupGetByIDRouter(svc service.AdminService) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() - h := NewUserHandler(svc, nil, nil, nil, nil, nil) + h := NewUserHandler(svc, nil, nil, nil, nil, nil, nil) r.GET("/admin/users/:id", h.GetByID) return r } diff --git a/backend/internal/handler/admin/user_handler_list_apikey_group_test.go b/backend/internal/handler/admin/user_handler_list_apikey_group_test.go index 0d14b62834..b03538b711 100644 --- a/backend/internal/handler/admin/user_handler_list_apikey_group_test.go +++ b/backend/internal/handler/admin/user_handler_list_apikey_group_test.go @@ -39,7 +39,7 @@ func TestAdminUserList_ParsesAPIKeyGroupID(t *testing.T) { t.Run(tc.name, func(t *testing.T) { stub := &listUsersFilterStub{AdminService: newStubAdminService()} r := gin.New() - h := NewUserHandler(stub, nil, nil, nil, nil, nil) + h := NewUserHandler(stub, nil, nil, nil, nil, nil, nil) r.GET("/admin/users", h.List) w := httptest.NewRecorder() diff --git a/backend/internal/handler/admin/user_handler_role_stepup_test.go b/backend/internal/handler/admin/user_handler_role_stepup_test.go index f4ffa3f65f..5a6345d0e2 100644 --- a/backend/internal/handler/admin/user_handler_role_stepup_test.go +++ b/backend/internal/handler/admin/user_handler_role_stepup_test.go @@ -29,7 +29,7 @@ func setupRoleStepUpRouter(t *testing.T) (*gin.Engine, *stubAdminService) { Status: service.StatusActive, }) - h := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) + h := NewUserHandler(adminSvc, nil, nil, nil, nil, nil, nil) router.POST("/api/v1/admin/users", h.Create) router.PUT("/api/v1/admin/users/:id", h.Update) return router, adminSvc diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index bb2a125157..fd7e7b6a52 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -37,6 +37,7 @@ type SystemSettings struct { TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 + StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 LoginAgreementEnabled bool `json:"login_agreement_enabled"` LoginAgreementMode string `json:"login_agreement_mode"` diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 57bf64d023..1f7186413a 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -153,9 +153,10 @@ func ProvideSettingHandler(settingService *service.SettingService, buildInfo Bui } // ProvideAdminSettingHandler creates admin.SettingHandler with notification template APIs. -func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService) *admin.SettingHandler { +func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService, totpService *service.TotpService, userService *service.UserService) *admin.SettingHandler { h := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService) h.SetNotificationEmailService(notificationEmailService) + h.SetStepUpDeps(totpService, userService) return h } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 955e9df1be..6b91652176 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -708,7 +708,8 @@ func TestAPIContracts(t *testing.T) { "frontend_url": "", "totp_enabled": false, "totp_encryption_key_configured": false, - "session_binding_enabled": true, + "session_binding_enabled": false, + "step_up_enabled": false, "audit_log_retention_days": 180, "login_agreement_enabled": false, "login_agreement_mode": "modal", @@ -1022,7 +1023,8 @@ func TestAPIContracts(t *testing.T) { "invitation_code_enabled": false, "totp_enabled": false, "totp_encryption_key_configured": false, - "session_binding_enabled": true, + "session_binding_enabled": false, + "step_up_enabled": false, "audit_log_retention_days": 180, "login_agreement_enabled": false, "login_agreement_mode": "modal", diff --git a/backend/internal/server/middleware/step_up.go b/backend/internal/server/middleware/step_up.go index b8ffc3d079..31a320e3fd 100644 --- a/backend/internal/server/middleware/step_up.go +++ b/backend/internal/server/middleware/step_up.go @@ -22,6 +22,11 @@ type stepUpUserReader interface { GetByID(ctx context.Context, id int64) (*service.User, error) } +// stepUpSettingReader 抽象 step-up 功能开关读取能力(由 SettingService 实现)。 +type stepUpSettingReader interface { + IsStepUpEnabled(ctx context.Context) bool +} + // StepUpSessionKey 计算 step-up 授权的会话键: // 优先绑定当前会话(refresh token family),无会话 ID 的旧 token 退化为用户级键。 func StepUpSessionKey(c *gin.Context, userID int64) string { @@ -33,19 +38,33 @@ func StepUpSessionKey(c *gin.Context, userID int64) string { // NewStepUpAuthMiddleware 创建敏感操作 step-up 2FA 门控中间件。 // -// 通过条件(全部满足): +// 功能开关 step_up_enabled(默认关闭)关闭时中间件直接放行,行为与门控引入前一致。 +// 开启时的通过条件(全部满足): // 1. 必须是 JWT 认证的真人会话——admin API key(机器凭证)一律拒绝 // 2. 当前用户已启用 TOTP(未启用则拒绝并提示先启用 2FA) // 3. 当前会话在有效期内完成过 TOTP step-up 验证(POST /api/v1/user/totp/step-up) // // 失败响应使用可区分的错误码,前端据此弹出 TOTP 验证对话框后重试。 -func NewStepUpAuthMiddleware(totpService *service.TotpService, userService *service.UserService) StepUpAuthMiddleware { - return StepUpAuthMiddleware(stepUpAuth(totpService, userService)) +func NewStepUpAuthMiddleware( + totpService *service.TotpService, + userService *service.UserService, + settingService *service.SettingService, +) StepUpAuthMiddleware { + return StepUpAuthMiddleware(stepUpAuth(totpService, userService, stepUpSettingsOrNil(settingService))) } -func stepUpAuth(grantChecker stepUpGrantChecker, userReader stepUpUserReader) gin.HandlerFunc { +// stepUpSettingsOrNil 将可能为 nil 的具体指针归一化为接口, +// 避免 typed-nil 装箱后绕过 enforceStepUp 内的 nil 判断。 +func stepUpSettingsOrNil(settingService *service.SettingService) stepUpSettingReader { + if settingService == nil { + return nil + } + return settingService +} + +func stepUpAuth(grantChecker stepUpGrantChecker, userReader stepUpUserReader, settings stepUpSettingReader) gin.HandlerFunc { return func(c *gin.Context) { - if !enforceStepUp(c, grantChecker, userReader) { + if !enforceStepUp(c, grantChecker, userReader, settings) { return } c.Next() @@ -55,11 +74,33 @@ func stepUpAuth(grantChecker stepUpGrantChecker, userReader stepUpUserReader) gi // EnforceStepUp 对当前请求执行与 StepUpAuthMiddleware 相同语义的 step-up 门控, // 供 handler 在需要按请求内容条件触发时调用(如仅当把用户角色提升为管理员时)。 // 校验失败时写入错误响应并中止请求,返回 false;通过返回 true。 -func EnforceStepUp(c *gin.Context, totpService *service.TotpService, userService *service.UserService) bool { - return enforceStepUp(c, totpService, userService) +func EnforceStepUp( + c *gin.Context, + totpService *service.TotpService, + userService *service.UserService, + settingService *service.SettingService, +) bool { + return enforceStepUp(c, totpService, userService, stepUpSettingsOrNil(settingService)) } -func enforceStepUp(c *gin.Context, grantChecker stepUpGrantChecker, userReader stepUpUserReader) bool { +// EnforceStepUpAlways 与 EnforceStepUp 语义相同但不读取功能开关,无条件执行门控。 +// 供调用方已确知门控必须生效的场景使用(如"关闭 step-up 开关"本身:调用方刚从 +// 持久化设置读到开关为开启状态,不应依赖二次读取——读取失败会导致门控被跳过)。 +func EnforceStepUpAlways( + c *gin.Context, + totpService *service.TotpService, + userService *service.UserService, +) bool { + return enforceStepUp(c, totpService, userService, nil) +} + +func enforceStepUp(c *gin.Context, grantChecker stepUpGrantChecker, userReader stepUpUserReader, settings stepUpSettingReader) bool { + // 功能开关关闭时直接放行(含 admin API key),恢复门控引入前的行为。 + // settings 为 nil 时保持门控(fail-closed):正常装配不会出现 nil。 + if settings != nil && !settings.IsStepUpEnabled(c.Request.Context()) { + return true + } + if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey { AbortWithError(c, 403, "STEP_UP_ADMIN_API_KEY_FORBIDDEN", "Admin API key cannot access this endpoint; a two-factor verified admin session is required") diff --git a/backend/internal/server/middleware/step_up_test.go b/backend/internal/server/middleware/step_up_test.go index 557d94d04c..d817d4659a 100644 --- a/backend/internal/server/middleware/step_up_test.go +++ b/backend/internal/server/middleware/step_up_test.go @@ -31,6 +31,17 @@ func (s stubStepUpUserReader) GetByID(ctx context.Context, id int64) (*service.U return s.user, s.err } +type stubStepUpSettingReader struct { + enabled bool +} + +func (s stubStepUpSettingReader) IsStepUpEnabled(ctx context.Context) bool { + return s.enabled +} + +// stepUpEnabled 功能开关开启的设置桩,供既有门控分支测试使用。 +var stepUpEnabled = stubStepUpSettingReader{enabled: true} + func newStepUpTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) { t.Helper() gin.SetMode(gin.TestMode) @@ -44,7 +55,7 @@ func TestEnforceStepUpRejectsAdminAPIKey(t *testing.T) { c, rec := newStepUpTestContext(t) c.Set("auth_method", service.AuditAuthMethodAdminAPIKey) - ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}) + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}, stepUpEnabled) require.False(t, ok) require.True(t, c.IsAborted()) @@ -55,7 +66,7 @@ func TestEnforceStepUpRejectsAdminAPIKey(t *testing.T) { func TestEnforceStepUpRequiresAuthSubject(t *testing.T) { c, rec := newStepUpTestContext(t) - ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}) + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}, stepUpEnabled) require.False(t, ok) require.Equal(t, http.StatusUnauthorized, rec.Code) @@ -65,7 +76,7 @@ func TestEnforceStepUpRequiresTotpEnabled(t *testing.T) { c, rec := newStepUpTestContext(t) c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) - ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: false}}) + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: false}}, stepUpEnabled) require.False(t, ok) require.Equal(t, http.StatusForbidden, rec.Code) @@ -76,7 +87,7 @@ func TestEnforceStepUpFailsClosedOnGrantError(t *testing.T) { c, rec := newStepUpTestContext(t) c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) - ok := enforceStepUp(c, stubStepUpGrantChecker{err: errors.New("redis down")}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}) + ok := enforceStepUp(c, stubStepUpGrantChecker{err: errors.New("redis down")}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}, stepUpEnabled) require.False(t, ok) require.Equal(t, http.StatusServiceUnavailable, rec.Code) @@ -87,7 +98,7 @@ func TestEnforceStepUpRequiresGrant(t *testing.T) { c, rec := newStepUpTestContext(t) c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) - ok := enforceStepUp(c, stubStepUpGrantChecker{granted: false}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}) + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: false}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}, stepUpEnabled) require.False(t, ok) require.Equal(t, http.StatusForbidden, rec.Code) @@ -98,8 +109,58 @@ func TestEnforceStepUpPassesWithGrant(t *testing.T) { c, _ := newStepUpTestContext(t) c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) - ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}) + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}, stepUpEnabled) require.True(t, ok) require.False(t, c.IsAborted()) } + +// 功能开关关闭时:不论 TOTP/grant/凭证类型,一律放行(恢复门控引入前行为)。 +func TestEnforceStepUpDisabledSkipsAllChecks(t *testing.T) { + disabled := stubStepUpSettingReader{enabled: false} + + t.Run("no totp, no grant", func(t *testing.T) { + c, _ := newStepUpTestContext(t) + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: false}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: false}}, disabled) + + require.True(t, ok) + require.False(t, c.IsAborted()) + }) + + t.Run("admin api key", func(t *testing.T) { + c, _ := newStepUpTestContext(t) + c.Set("auth_method", service.AuditAuthMethodAdminAPIKey) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: false}, stubStepUpUserReader{user: nil, err: errors.New("should not be called")}, disabled) + + require.True(t, ok) + require.False(t, c.IsAborted()) + }) +} + +// settings 为 nil 时保持门控(fail-closed),避免装配缺陷静默关闭安全控制。 +func TestEnforceStepUpNilSettingsFailsClosed(t *testing.T) { + c, rec := newStepUpTestContext(t) + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: false}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}, nil) + + require.False(t, ok) + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_REQUIRED") +} + +// EnforceStepUp 收到 nil *service.SettingService 时不得因 typed-nil 装箱绕过门控: +// 未认证请求仍应被拦截(401),而不是当作"开关关闭"放行。 +func TestEnforceStepUpTypedNilSettingServiceFailsClosed(t *testing.T) { + require.Nil(t, stepUpSettingsOrNil(nil)) + + c, rec := newStepUpTestContext(t) + + ok := EnforceStepUp(c, nil, nil, nil) + + require.False(t, ok) + require.Equal(t, http.StatusUnauthorized, rec.Code) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index a76c5d815f..54cf0ae95a 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -586,8 +586,8 @@ func registerBackupRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAut // 备份下载链接可直接取走整库数据——要求 step-up 2FA backup.GET("/:id/download-url", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.GetDownloadURL) - // 恢复操作 - backup.POST("/:id/restore", h.Admin.Backup.RestoreBackup) + // 恢复操作:整库覆盖可回滚安全设置(含 step-up 开关本身)——要求 step-up 2FA + backup.POST("/:id/restore", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.RestoreBackup) } } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 96a0a39c7c..cbbb4b7850 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -170,7 +170,10 @@ const ( SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能 // 会话安全设置 - SettingKeySessionBindingEnabled = "session_binding_enabled" // 会话 IP/UA 绑定(变更即失效),默认开启 + SettingKeySessionBindingEnabled = "session_binding_enabled" // 会话 IP/UA 绑定(变更即失效),默认关闭 + + // 敏感操作 step-up 2FA 设置 + SettingKeyStepUpEnabled = "step_up_enabled" // 敏感操作(导出/备份/S3配置/提升管理员等)要求 step-up 2FA,默认关闭 // 操作审计日志设置 SettingKeyAuditLogRetentionDays = "audit_log_retention_days" // 审计日志保留天数(<=0 永久保留),默认 180 diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 8543ad6f01..52ba3670ff 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -179,14 +179,26 @@ func (s *SettingService) IsTotpEncryptionKeyConfigured() bool { return s.cfg.Totp.EncryptionKeyConfigured } -// IsSessionBindingEnabled 检查会话 IP/UA 绑定是否启用(默认开启)。 +// IsSessionBindingEnabled 检查会话 IP/UA 绑定是否启用(默认关闭)。 // 开启时会话与登录时的 IP/User-Agent 绑定,任一变化立即失效并撤销该会话。 +// 默认关闭:移动网络/多出口 IP 场景下 IP 频繁变化会导致登录后立即掉线。 func (s *SettingService) IsSessionBindingEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeySessionBindingEnabled) if err != nil { - return true // 默认开启 + return false // 默认关闭 } - return value != "false" + return value == "true" +} + +// IsStepUpEnabled 检查敏感操作 step-up 2FA 门控是否启用(默认关闭)。 +// 开启时账号/代理导出、备份创建/下载、S3 配置修改、提升管理员等操作 +// 要求当前会话在有效期内完成过 TOTP step-up 验证。 +func (s *SettingService) IsStepUpEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyStepUpEnabled) + if err != nil { + return false // 默认关闭 + } + return value == "true" } // defaultAuditLogRetentionDays 审计日志默认保留天数。 diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index a3aec1dd7f..a56e57784e 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -260,7 +260,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin FrontendURL: settings[SettingKeyFrontendURL], InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] != "false", // 默认开启 + SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] == "true", // 默认关闭 + StepUpEnabled: settings[SettingKeyStepUpEnabled] == "true", // 默认关闭 AuditLogRetentionDays: parseAuditLogRetentionDays(settings[SettingKeyAuditLogRetentionDays]), LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true", LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 686f315a85..96c078e422 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -122,6 +122,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) updates[SettingKeySessionBindingEnabled] = strconv.FormatBool(settings.SessionBindingEnabled) + updates[SettingKeyStepUpEnabled] = strconv.FormatBool(settings.StepUpEnabled) updates[SettingKeyAuditLogRetentionDays] = strconv.Itoa(settings.AuditLogRetentionDays) settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode) settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 9cfae8a8c6..85ab619d14 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -21,6 +21,7 @@ type SystemSettings struct { InvitationCodeEnabled bool TotpEnabled bool // TOTP 双因素认证 SessionBindingEnabled bool // 会话 IP/UA 绑定(变更即失效) + StepUpEnabled bool // 敏感操作 step-up 2FA 门控 AuditLogRetentionDays int // 审计日志保留天数(<=0 永久保留) LoginAgreementEnabled bool LoginAgreementMode string diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index b68846be1f..33b1f7f97d 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -367,6 +367,7 @@ export interface SystemSettings { totp_enabled: boolean; // TOTP 双因素认证 totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置 session_binding_enabled: boolean; // 会话 IP/UA 绑定 + step_up_enabled: boolean; // 敏感操作 step-up 2FA audit_log_retention_days: number; // 审计日志保留天数 login_agreement_enabled: boolean; login_agreement_mode: "modal" | "checkbox" | string; @@ -672,6 +673,7 @@ export interface UpdateSettingsRequest { invitation_code_enabled?: boolean; totp_enabled?: boolean; // TOTP 双因素认证 session_binding_enabled?: boolean; // 会话 IP/UA 绑定 + step_up_enabled?: boolean; // 敏感操作 step-up 2FA audit_log_retention_days?: number; // 审计日志保留天数 login_agreement_enabled?: boolean; login_agreement_mode?: "modal" | "checkbox" | string; diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index f177122e7f..8c57ab4158 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -127,6 +127,9 @@ export default { 'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32' }, security: { + stepUp: 'Step-up 2FA for Sensitive Operations', + stepUpHint: 'When enabled, sensitive operations (account/proxy export, backup creation and download, S3 config changes, promoting admins) require a recent TOTP verification (valid for 15 minutes). Your own account must have 2FA enabled before turning this on; turning it off also requires step-up verification.', + stepUpEnableRequiresTotp: 'Enable 2FA (TOTP) for your own account in Profile before turning on step-up verification.', sessionBinding: 'Session IP/UA Binding', sessionBindingHint: 'Bind login sessions to the client IP and User-Agent. Any change immediately invalidates the session and forces re-login, raising the bar for stolen-credential reuse.', auditRetention: 'Audit Log Retention (days)', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 3b4b568615..49f43dc059 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -127,6 +127,9 @@ export default { '请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。' }, security: { + stepUp: '敏感操作二次验证 (step-up 2FA)', + stepUpHint: '开启后,账号/代理导出、备份创建与下载、S3 配置修改、提升管理员等敏感操作需要先完成 TOTP 二次验证(15 分钟内有效)。开启前需本人已启用 2FA;关闭该开关本身也需要二次验证。', + stepUpEnableRequiresTotp: '开启敏感操作二次验证前,请先在个人资料中为当前账号启用 2FA (TOTP)。', sessionBinding: '会话 IP/UA 绑定', sessionBindingHint: '将登录会话与客户端 IP 和 User-Agent 绑定,任一变化即强制该会话失效并需重新登录(提升被盗凭证的利用门槛)。', auditRetention: '操作日志保留天数', diff --git a/frontend/src/views/admin/BackupView.vue b/frontend/src/views/admin/BackupView.vue index 223973beef..a2223f9412 100644 --- a/frontend/src/views/admin/BackupView.vue +++ b/frontend/src/views/admin/BackupView.vue @@ -586,16 +586,19 @@ async function restoreBackup(id: string) { if (!password) return restoringId.value = id try { - const record = await adminAPI.backup.restoreBackup(id, password) + const record = await backupStepUp.run(() => adminAPI.backup.restoreBackup(id, password)) updateRecordInList(record) startRestorePolling(id) } catch (error: any) { - if (error?.response?.status === 409) { + restoringId.value = '' + if (isStepUpCancelled(error)) return + if (reportStepUpBlocked(error)) return + // apiClient 拦截器把 HTTP 错误归一化为顶层 { status } 平面对象(无 response 字段) + if (error?.status === 409 || error?.response?.status === 409) { appStore.showWarning(t('admin.backup.operations.restoreRunning')) } else { appStore.showError(error?.message || t('errors.networkError')) } - restoringId.value = '' } } diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 271ef3191f..45ba18336a 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1578,6 +1578,21 @@ /> + +
+
+ +

+ {{ t("admin.settings.security.stepUpHint") }} +

+
+ +
+
+ +
@@ -7519,6 +7536,13 @@ import BackupSettings from "@/views/admin/BackupView.vue"; import EmailTemplateEditor from "@/views/admin/settings/EmailTemplateEditor.vue"; import OpenAIFastPolicyUserSelector from "@/views/admin/settings/OpenAIFastPolicyUserSelector.vue"; import { useClipboard } from "@/composables/useClipboard"; +import { + useStepUp, + isStepUpCancelled, + isStepUpBlocked, + stepUpBlockReason, +} from "@/composables/useStepUp"; +import TotpStepUpDialog from "@/components/auth/TotpStepUpDialog.vue"; import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates"; import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError"; import { useAppStore } from "@/stores"; @@ -7539,6 +7563,8 @@ import { const { t, locale } = useI18n(); const appStore = useAppStore(); +// 关闭 step-up 开关是敏感操作:后端返回 STEP_UP_REQUIRED 时弹 TOTP 码重试 +const settingsStepUp = useStepUp(); const adminSettingsStore = useAdminSettingsStore(); const isZhLocale = computed(() => locale.value.startsWith("zh")); @@ -8192,7 +8218,8 @@ const form = reactive({ password_reset_enabled: false, totp_enabled: false, totp_encryption_key_configured: false, - session_binding_enabled: true, + session_binding_enabled: false, + step_up_enabled: false, audit_log_retention_days: 180, login_agreement_enabled: false, login_agreement_mode: "modal", @@ -9559,6 +9586,7 @@ async function saveSettings() { password_reset_enabled: form.password_reset_enabled, totp_enabled: form.totp_enabled, session_binding_enabled: form.session_binding_enabled, + step_up_enabled: form.step_up_enabled, // 清空数字框时 v-model.number 会得到空串,后端 int 字段解析空串会 400 拒绝整次保存; // 空/非法值回退默认 180(与后端 parseAuditLogRetentionDays("") 语义一致,0 仍表示永久保留)。 audit_log_retention_days: Number.isFinite(form.audit_log_retention_days) @@ -9856,7 +9884,9 @@ async function saveSettings() { payload.default_platform_quotas = sanitizePlatformQuotasMap(form.default_platform_quotas); appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults); - const updated = await adminAPI.settings.updateSettings(payload); + const updated = await settingsStepUp.run(() => + adminAPI.settings.updateSettings(payload), + ); for (const [key, value] of Object.entries(updated)) { if (key === "openai_fast_policy_settings") continue; if (value !== null && value !== undefined) { @@ -9930,6 +9960,25 @@ async function saveSettings() { appStore.showSuccess(t("admin.settings.settingsSaved")); } } catch (error: unknown) { + // 用户取消 step-up 验证:静默返回,不弹错误 + if (isStepUpCancelled(error)) { + return; + } + if (isStepUpBlocked(error)) { + appStore.showError( + stepUpBlockReason(error) === "STEP_UP_ADMIN_API_KEY_FORBIDDEN" + ? t("stepUp.adminApiKeyForbidden") + : t("stepUp.notEnabled"), + ); + return; + } + // 开启 step-up 开关但本人未启用 2FA:给出可操作的专用提示 + if ( + (error as { reason?: string })?.reason === "STEP_UP_ENABLE_REQUIRES_TOTP" + ) { + appStore.showError(t("admin.settings.security.stepUpEnableRequiresTotp")); + return; + } appStore.showError( extractApiErrorMessage(error, t("admin.settings.failedToSave")), );