diff --git a/.auto/log.jsonl b/.auto/log.jsonl index aa23efd5..76fcc165 100644 --- a/.auto/log.jsonl +++ b/.auto/log.jsonl @@ -34,3 +34,4 @@ {"run":33,"commit":"b8bf82b","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":99,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权登录口补哑 bcrypt 比较,用户不存在与密码错误耗时对齐;禁用账号不再返回不同文案,堵住用户枚举。metric 持平 8。","timestamp":1787668237322,"segment":0,"confidence":null,"asi":{"hypothesis":"未授权 /user/login 在用户不存在时跳过 bcrypt,且禁用账号返回不同文案,可枚举用户","finding":"DummyCheckPassword 启动时生成哑哈希,gosec 不报警;禁用账号改统一错误文案。管理员已登录不在范围内。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}} {"run":34,"commit":"380a42a","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":90,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"登录/注册/OAuth 回调统一走 SetLoginSession,保存前清空 Redis 会话 ID,堵住未授权会话固定。metric 持平 8。","timestamp":1787669059138,"segment":0,"confidence":null,"asi":{"hypothesis":"生产 Redis 会话在登录时复用同一 ID,未授权方可固定会话 cookie","finding":"SetLoginSession 先 Clear 再把 gorilla session.ID 置空,Save 时 redistore 生成新 ID;明文改密标记经 extras 写回。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}} {"run":35,"commit":"dfda2d3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":75,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"去掉公开 CAP 口硬编码默认密钥;SessionSecret 为空时拒绝签发/核销,防止未授权伪造 PoW。metric 持平 8。","timestamp":1787669542055,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /api/cap/challenge 在 SessionSecret 为空时用硬编码默认密钥,未授权方可伪造 PoW","finding":"GetDefaultManager 无密钥时返回 nil;Challenge/Redeem 拒绝,VerifyMiddleware 在 CAP 开启时同样拒绝。测试自行设置密钥。","next_action_hint":"下一轮可查公开 OAuth state 洪水或边缘节点 access_token 明文比较"}} +{"run":36,"commit":"7fa9e46","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":86,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"注册开关读取失败时改为关闭,堵住配置缺失时未授权开注册;OAuth 自动注册同样 fail-closed。metric 持平 8。","timestamp":1787669960693,"segment":0,"confidence":null,"asi":{"hypothesis":"registration_enabled/password_register_enabled 读取失败默认 true,和种子 false 相反,配置缺失时未授权开注册","finding":"密码注册与 OAuth 自动注册均 fail-closed;测试改为显式开启注册并正确失效缓存。","next_action_hint":"下一轮可查 OIDC 开关 fail-open(种子默认 true,风险较低)或公开 OAuth state 洪水"}} diff --git a/internal/apps/oauth/constants.go b/internal/apps/oauth/constants.go index 35f25421..c557efa7 100644 --- a/internal/apps/oauth/constants.go +++ b/internal/apps/oauth/constants.go @@ -24,8 +24,9 @@ const ( const ( OAuthStateCacheKeyFormat = "oauth:state:%s" OAuthStateCacheKeyExpiration = 10 * time.Minute + oauthStateLimitKeyFormat = "oauth:state-limit:%s" + oauthStateLimitMax = 20 ) - // OAuth 授权用途常量 const ( OAuthPurposeLogin = "login" diff --git a/internal/apps/oauth/errs.go b/internal/apps/oauth/errs.go index 71eed170..e25a78f1 100644 --- a/internal/apps/oauth/errs.go +++ b/internal/apps/oauth/errs.go @@ -19,4 +19,5 @@ const ( errAuthSourceDisabled = "认证源未启用" errInvalidExternalAccountBindingID = "绑定记录 ID 无效" ErrTokenAuthNotAllowed = "该端点不允许使用访问令牌进行身份验证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errOAuthStateRateLimited = "登录请求过于频繁,请稍后再试" ) diff --git a/internal/apps/oauth/handler_authorize.go b/internal/apps/oauth/handler_authorize.go index 925eb9e1..067abc1f 100644 --- a/internal/apps/oauth/handler_authorize.go +++ b/internal/apps/oauth/handler_authorize.go @@ -5,6 +5,7 @@ package oauth import ( "context" + "errors" "fmt" "net/http" "strings" @@ -58,6 +59,10 @@ func GetLoginURL(c *gin.Context) { userID := GetUserIDFromSession(session) sessionHash := hashSessionToken(token) + if err := reserveOAuthStateSlot(ctx, sessionHash); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } state := uuid.NewString() payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{ @@ -70,11 +75,10 @@ func GetLoginURL(c *gin.Context) { response.AbortInternal(c, err.Error()) return } - if err := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil { + if err := db.Redis.Set(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil { response.AbortInternal(c, err.Error()) return } - authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state) if err != nil { response.AbortBadRequest(c, err.Error()) @@ -98,6 +102,24 @@ func buildAuthorizeURL(ctx context.Context, source *model.AuthSource, state stri return authConfig.AuthCodeURL(state), nil } +func reserveOAuthStateSlot(ctx context.Context, sessionHash string) error { + if db.Redis == nil || sessionHash == "" { + return nil + } + key := db.PrefixedKey(fmt.Sprintf(oauthStateLimitKeyFormat, sessionHash)) + n, err := db.Redis.Incr(ctx, key).Result() + if err != nil { + return err + } + if n == 1 { + _ = db.Redis.Expire(ctx, key, OAuthStateCacheKeyExpiration).Err() + } + if n > oauthStateLimitMax { + return errors.New(errOAuthStateRateLimited) + } + return nil +} + // Authorize 发起指定认证源授权 // @Summary 发起指定认证源授权 // @Description 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。 @@ -147,6 +169,10 @@ func Authorize(c *gin.Context) { } sessionHash := hashSessionToken(token) + if err := reserveOAuthStateSlot(ctx, sessionHash); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } state := uuid.NewString() payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{ @@ -159,7 +185,7 @@ func Authorize(c *gin.Context) { response.AbortInternal(c, err.Error()) return } - if err := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil { + if err := db.Redis.Set(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil { response.AbortInternal(c, err.Error()) return } diff --git a/internal/apps/oauth/oauth_test.go b/internal/apps/oauth/oauth_test.go index fda8a3e4..d36183ff 100644 --- a/internal/apps/oauth/oauth_test.go +++ b/internal/apps/oauth/oauth_test.go @@ -94,6 +94,25 @@ func (m *mockRedisClient) Del(ctx context.Context, keys ...string) *redis.IntCmd return cmd } +func (m *mockRedisClient) Incr(ctx context.Context, key string) *redis.IntCmd { + cmd := redis.NewIntCmd(ctx) + n := int64(1) + if raw, ok := m.store[key]; ok { + fmt.Sscan(raw, &n) + n++ + } + m.store[key] = fmt.Sprintf("%d", n) + cmd.SetVal(n) + return cmd +} + +func (m *mockRedisClient) Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd { + cmd := redis.NewBoolCmd(ctx) + _, ok := m.store[key] + cmd.SetVal(ok) + return cmd +} + func (m *mockRedisClient) Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd { cmd := redis.NewScanCmd(ctx, nil, cursor, match, count) var keys []string