feat(security): 敏感操作 step-up 2FA 开关化,安全开关默认关闭

新增系统设置 step_up_enabled(默认关闭),把敏感操作 2FA 门控做成可开关;
同时将会话 IP/UA 绑定默认值从开启改为关闭,避免用户因 IP 变动登录后掉线。

## 新增功能
- 敏感操作 step-up 2FA 总开关 step_up_enabled(默认关闭):关闭时账号/代理导出、
  备份创建/下载、S3 配置修改、提升管理员等操作恢复门控引入前的直接放行行为;
  开启后要求当前会话在 15 分钟内完成过 TOTP step-up 验证。

## 优化改进
- 会话 IP/UA 绑定默认改为关闭(功能保留,可在设置页按需开启)。
- 开启 step-up 开关需操作者本人已启用 TOTP(防自锁);关闭开关本身作为敏感操作,
  需通过 step-up 验证(防止攻击者拿到会话后先关闸再导出/备份)。
- 两个安全开关请求字段改为可空指针(省略=保持现值),避免旧客户端全量保存时
  静默重置安全开关。
- 备份恢复(整库覆盖可回滚安全设置)纳入 step-up 门控。
- 审计摘要 diffSettings 补记 step_up_enabled / session_binding_enabled 变更。

## Bug 修复
- 修复 BackupView 恢复操作 409(恢复进行中)判断未适配 apiClient 扁平化错误对象。
This commit is contained in:
shaw
2026-07-18 10:46:42 +08:00
parent 57914967cb
commit 539bfc8bad
28 changed files with 472 additions and 43 deletions
+3 -3
View File
@@ -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)
@@ -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)
@@ -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,
@@ -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")
}
@@ -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])
}
@@ -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,
@@ -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
}
}
@@ -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)
@@ -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
}
@@ -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
}
@@ -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()
@@ -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
+1
View File
@@ -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"`
+2 -1
View File
@@ -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
}
+4 -2
View File
@@ -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",
+49 -8
View File
@@ -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")
@@ -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)
}
+2 -2
View File
@@ -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)
}
}
+4 -1
View File
@@ -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
+15 -3
View File
@@ -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 审计日志默认保留天数。
+2 -1
View File
@@ -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]),
@@ -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)
@@ -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
+2
View File
@@ -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;
@@ -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)',
@@ -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: '操作日志保留天数',
+6 -3
View File
@@ -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 = ''
}
}
+51 -2
View File
@@ -1578,6 +1578,21 @@
/>
</div>
<!-- 敏感操作 step-up 2FA -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.security.stepUp")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.security.stepUpHint") }}
</p>
</div>
<Toggle v-model="form.step_up_enabled" />
</div>
<!-- 会话 IP/UA 绑定 -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
@@ -7466,6 +7481,8 @@
@confirm="handleAffiliateConfirm"
@cancel="cancelAffiliateConfirm"
/>
<!-- 关闭 step-up 开关等敏感保存操作触发的 TOTP 二次验证 -->
<TotpStepUpDialog :controller="settingsStepUp" />
</div>
</AppLayout>
</template>
@@ -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<SettingsForm>({
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")),
);