mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user