新增系统设置 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 扁平化错误对象。
167 lines
5.4 KiB
Go
167 lines
5.4 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type stubStepUpGrantChecker struct {
|
|
granted bool
|
|
err error
|
|
}
|
|
|
|
func (s stubStepUpGrantChecker) HasStepUpGrant(ctx context.Context, userID int64, sessionKey string) (bool, error) {
|
|
return s.granted, s.err
|
|
}
|
|
|
|
type stubStepUpUserReader struct {
|
|
user *service.User
|
|
err error
|
|
}
|
|
|
|
func (s stubStepUpUserReader) GetByID(ctx context.Context, id int64) (*service.User, error) {
|
|
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)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/sensitive", nil)
|
|
return c, rec
|
|
}
|
|
|
|
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}}, stepUpEnabled)
|
|
|
|
require.False(t, ok)
|
|
require.True(t, c.IsAborted())
|
|
require.Equal(t, http.StatusForbidden, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN")
|
|
}
|
|
|
|
func TestEnforceStepUpRequiresAuthSubject(t *testing.T) {
|
|
c, rec := newStepUpTestContext(t)
|
|
|
|
ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}, stepUpEnabled)
|
|
|
|
require.False(t, ok)
|
|
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
|
}
|
|
|
|
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}}, stepUpEnabled)
|
|
|
|
require.False(t, ok)
|
|
require.Equal(t, http.StatusForbidden, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "STEP_UP_TOTP_NOT_ENABLED")
|
|
}
|
|
|
|
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}}, stepUpEnabled)
|
|
|
|
require.False(t, ok)
|
|
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "STEP_UP_UNAVAILABLE")
|
|
}
|
|
|
|
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}}, stepUpEnabled)
|
|
|
|
require.False(t, ok)
|
|
require.Equal(t, http.StatusForbidden, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "STEP_UP_REQUIRED")
|
|
}
|
|
|
|
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}}, 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)
|
|
}
|