grpc_rate_limit_audit_test.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. package server
  2. import (
  3. "context"
  4. "database/sql"
  5. "net"
  6. "testing"
  7. "time"
  8. authHelper "perms-system-server/internal/logic/auth"
  9. userModel "perms-system-server/internal/model/user"
  10. "perms-system-server/internal/svc"
  11. "perms-system-server/internal/testutil"
  12. "perms-system-server/pb"
  13. "github.com/stretchr/testify/assert"
  14. "github.com/stretchr/testify/require"
  15. "github.com/zeromicro/go-zero/core/limit"
  16. "github.com/zeromicro/go-zero/core/stores/redis"
  17. "google.golang.org/grpc/codes"
  18. "google.golang.org/grpc/peer"
  19. "google.golang.org/grpc/status"
  20. )
  21. // ---------------------------------------------------------------------------
  22. // 覆盖目标:
  23. // H-2:gRPC RefreshToken / VerifyToken 必须受 IP 维度限流保护;
  24. // M-7:extractClientIP 必须剥离端口,同一 IP 的不同 TCP 端口共享同一限流桶;
  25. // H-1:gRPC RefreshToken 里走的是 IncrementTokenVersionIfMatch,
  26. // 一次成功后旧 refreshToken 立刻失效;重放必须返回 Unauthenticated。
  27. // ---------------------------------------------------------------------------
  28. // withPeerIP 往 ctx 注入指定 "host:port" 的 peer,模拟 gRPC 上游的 PeerAddr。
  29. func withPeerIP(ctx context.Context, hostPort string) context.Context {
  30. addr, err := net.ResolveTCPAddr("tcp", hostPort)
  31. if err != nil {
  32. panic(err)
  33. }
  34. return peer.NewContext(ctx, &peer.Peer{Addr: addr})
  35. }
  36. // TC-0828: H-2 —— GrpcRefreshLimiter 在配额用尽后对同 IP 新请求返回 ResourceExhausted。
  37. func TestGrpcRefreshToken_RateLimit_OverIP(t *testing.T) {
  38. ctx := context.Background()
  39. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  40. cfg := testutil.GetTestConfig()
  41. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  42. // quota=1 的定制 limiter,让第 2 次必然 429/ResourceExhausted。
  43. svcCtx.GrpcRefreshLimiter = limit.NewPeriodLimit(
  44. 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:refresh:ut:"+testutil.UniqueId())
  45. svcCtx.TokenOpLimiter = nil
  46. srv := NewPermServer(svcCtx)
  47. // 第 1 次:故意用个无效 token,让 limiter 放行、业务层兜底返回 Unauthenticated。
  48. // 这里只关心 limiter 是否"吃掉 1 个配额"。
  49. ctx1 := withPeerIP(ctx, "10.1.2.3:11111")
  50. _, err1 := srv.RefreshToken(ctx1, &pb.RefreshTokenReq{RefreshToken: "invalid"})
  51. require.Error(t, err1)
  52. st1, _ := status.FromError(err1)
  53. assert.Equal(t, codes.Unauthenticated, st1.Code(),
  54. "首次放行,业务层应返回 Unauthenticated(token 无效),不应是 ResourceExhausted")
  55. // 第 2 次:同 IP 但端口不同(模拟新 TCP 连接),必须被同一限流桶拦住。
  56. ctx2 := withPeerIP(ctx, "10.1.2.3:22222")
  57. _, err2 := srv.RefreshToken(ctx2, &pb.RefreshTokenReq{RefreshToken: "anything"})
  58. require.Error(t, err2)
  59. st2, _ := status.FromError(err2)
  60. assert.Equal(t, codes.ResourceExhausted, st2.Code(),
  61. "H-2 + M-7:同 IP 第 2 次刷新必须 429;端口变化不得绕过限流(extractClientIP 剥端口)")
  62. assert.Contains(t, st2.Message(), "过于频繁")
  63. }
  64. // TC-0829: H-2 —— GrpcVerifyLimiter 在配额用尽后对同 IP 新请求返回 ResourceExhausted。
  65. // VerifyToken 契约是"非法 token 返回 Valid=false 而不是 error",因此限流是唯一能让接口返回 gRPC error 的路径。
  66. func TestGrpcVerifyToken_RateLimit_OverIP(t *testing.T) {
  67. ctx := context.Background()
  68. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  69. cfg := testutil.GetTestConfig()
  70. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  71. svcCtx.GrpcVerifyLimiter = limit.NewPeriodLimit(
  72. 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:verify:ut:"+testutil.UniqueId())
  73. srv := NewPermServer(svcCtx)
  74. ctx1 := withPeerIP(ctx, "10.9.8.7:30001")
  75. resp1, err1 := srv.VerifyToken(ctx1, &pb.VerifyTokenReq{AccessToken: "invalid"})
  76. require.NoError(t, err1, "首次放行:VerifyToken 对非法 token 只返回 Valid=false,不 error")
  77. require.NotNil(t, resp1)
  78. assert.False(t, resp1.Valid)
  79. // 同 IP 不同端口 → 必须被限流拦住。
  80. ctx2 := withPeerIP(ctx, "10.9.8.7:30002")
  81. _, err2 := srv.VerifyToken(ctx2, &pb.VerifyTokenReq{AccessToken: "whatever"})
  82. require.Error(t, err2)
  83. st2, _ := status.FromError(err2)
  84. assert.Equal(t, codes.ResourceExhausted, st2.Code(),
  85. "H-2:gRPC VerifyToken 必须受 IP 级限流保护,防止下游被当 token oracle 爆破")
  86. }
  87. // TC-0830: M-7 —— extractClientIP 对 "host:port" 必须剥成 host;
  88. // 缺失 peer 时返回 error,由上层决定降级到 unknown 桶。
  89. func TestExtractClientIP_StripsPort(t *testing.T) {
  90. addr, err := net.ResolveTCPAddr("tcp", "192.168.0.1:54321")
  91. require.NoError(t, err)
  92. ctx := peer.NewContext(context.Background(), &peer.Peer{Addr: addr})
  93. ip, err := extractClientIP(ctx)
  94. require.NoError(t, err)
  95. assert.Equal(t, "192.168.0.1", ip,
  96. "M-7:gRPC peer.Addr 必须剥成纯 host;保留端口会导致限流形同虚设")
  97. // 无 peer 的 context
  98. _, err2 := extractClientIP(context.Background())
  99. assert.Error(t, err2, "无 peer 时必须返回 error,让上层选择 fail-close 或降级到 unknown 桶")
  100. }
  101. // TC-0831: H-1 + M-7 —— gRPC RefreshToken 成功一次后,旧 refreshToken 立刻失效;
  102. // 换用同 IP 重放旧 token 必须返回 Unauthenticated("登录状态已失效"),
  103. // 而不是因端口变化绕过限流或因 CAS 失败被伪装成 500。
  104. func TestGrpcRefreshToken_CASInvalidatesOldToken(t *testing.T) {
  105. ctx := context.Background()
  106. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  107. conn := testutil.GetTestSqlConn()
  108. cfg := testutil.GetTestConfig()
  109. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  110. // 放开限流以聚焦 CAS 正确性(quota 大)。
  111. svcCtx.GrpcRefreshLimiter = limit.NewPeriodLimit(
  112. 60, 100, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:refresh:cas:"+testutil.UniqueId())
  113. svcCtx.TokenOpLimiter = nil
  114. now := time.Now().Unix()
  115. uid := testutil.UniqueId()
  116. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  117. Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "n",
  118. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  119. Status: 1, CreateTime: now, UpdateTime: now,
  120. })
  121. require.NoError(t, err)
  122. userId, _ := uRes.LastInsertId()
  123. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  124. rt, err := authHelper.GenerateRefreshToken(cfg.Auth.RefreshSecret, cfg.Auth.RefreshExpire, userId, "", 0)
  125. require.NoError(t, err)
  126. srv := NewPermServer(svcCtx)
  127. // 第一次成功刷新。
  128. ctx1 := withPeerIP(ctx, "172.16.0.1:11001")
  129. resp, err := srv.RefreshToken(ctx1, &pb.RefreshTokenReq{RefreshToken: rt})
  130. require.NoError(t, err)
  131. require.NotEmpty(t, resp.RefreshToken)
  132. // 用同一个旧 rt 重放,应当 Unauthenticated;
  133. // 注意:旧 token 里 tokenVersion=0,DB 已被 CAS 推到 1,所以 "claims.TokenVersion != ud.TokenVersion" 这一步就会拦住。
  134. // 端口换掉以确保不是限流在帮我们挡。
  135. ctx2 := withPeerIP(ctx, "172.16.0.1:11002")
  136. _, err = srv.RefreshToken(ctx2, &pb.RefreshTokenReq{RefreshToken: rt})
  137. require.Error(t, err, "H-1:旧 refreshToken 成功刷新一次后必须失效")
  138. st, _ := status.FromError(err)
  139. assert.Equal(t, codes.Unauthenticated, st.Code(),
  140. "旧 token 重放必须返回 Unauthenticated,不能是 Internal/ResourceExhausted")
  141. assert.Contains(t, st.Message(), "登录状态已失效")
  142. }