mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:18:25 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 审计日志默认保留天数。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '操作日志保留天数',
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user