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
+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)
}
}