| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165 |
- package server
- import (
- "context"
- "database/sql"
- "net"
- "testing"
- "time"
- authHelper "perms-system-server/internal/logic/auth"
- userModel "perms-system-server/internal/model/user"
- "perms-system-server/internal/svc"
- "perms-system-server/internal/testutil"
- "perms-system-server/pb"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "github.com/zeromicro/go-zero/core/limit"
- "github.com/zeromicro/go-zero/core/stores/redis"
- "google.golang.org/grpc/codes"
- "google.golang.org/grpc/peer"
- "google.golang.org/grpc/status"
- )
- // ---------------------------------------------------------------------------
- // 覆盖目标:
- // H-2:gRPC RefreshToken / VerifyToken 必须受 IP 维度限流保护;
- // M-7:extractClientIP 必须剥离端口,同一 IP 的不同 TCP 端口共享同一限流桶;
- // H-1:gRPC RefreshToken 里走的是 IncrementTokenVersionIfMatch,
- // 一次成功后旧 refreshToken 立刻失效;重放必须返回 Unauthenticated。
- // ---------------------------------------------------------------------------
- // withPeerIP 往 ctx 注入指定 "host:port" 的 peer,模拟 gRPC 上游的 PeerAddr。
- func withPeerIP(ctx context.Context, hostPort string) context.Context {
- addr, err := net.ResolveTCPAddr("tcp", hostPort)
- if err != nil {
- panic(err)
- }
- return peer.NewContext(ctx, &peer.Peer{Addr: addr})
- }
- // TC-0828: H-2 —— GrpcRefreshLimiter 在配额用尽后对同 IP 新请求返回 ResourceExhausted。
- func TestGrpcRefreshToken_RateLimit_OverIP(t *testing.T) {
- ctx := context.Background()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- cfg := testutil.GetTestConfig()
- rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
- // quota=1 的定制 limiter,让第 2 次必然 429/ResourceExhausted。
- svcCtx.GrpcRefreshLimiter = limit.NewPeriodLimit(
- 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:refresh:ut:"+testutil.UniqueId())
- svcCtx.TokenOpLimiter = nil
- srv := NewPermServer(svcCtx)
- // 第 1 次:故意用个无效 token,让 limiter 放行、业务层兜底返回 Unauthenticated。
- // 这里只关心 limiter 是否"吃掉 1 个配额"。
- ctx1 := withPeerIP(ctx, "10.1.2.3:11111")
- _, err1 := srv.RefreshToken(ctx1, &pb.RefreshTokenReq{RefreshToken: "invalid"})
- require.Error(t, err1)
- st1, _ := status.FromError(err1)
- assert.Equal(t, codes.Unauthenticated, st1.Code(),
- "首次放行,业务层应返回 Unauthenticated(token 无效),不应是 ResourceExhausted")
- // 第 2 次:同 IP 但端口不同(模拟新 TCP 连接),必须被同一限流桶拦住。
- ctx2 := withPeerIP(ctx, "10.1.2.3:22222")
- _, err2 := srv.RefreshToken(ctx2, &pb.RefreshTokenReq{RefreshToken: "anything"})
- require.Error(t, err2)
- st2, _ := status.FromError(err2)
- assert.Equal(t, codes.ResourceExhausted, st2.Code(),
- "H-2 + M-7:同 IP 第 2 次刷新必须 429;端口变化不得绕过限流(extractClientIP 剥端口)")
- assert.Contains(t, st2.Message(), "过于频繁")
- }
- // TC-0829: H-2 —— GrpcVerifyLimiter 在配额用尽后对同 IP 新请求返回 ResourceExhausted。
- // VerifyToken 契约是"非法 token 返回 Valid=false 而不是 error",因此限流是唯一能让接口返回 gRPC error 的路径。
- func TestGrpcVerifyToken_RateLimit_OverIP(t *testing.T) {
- ctx := context.Background()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- cfg := testutil.GetTestConfig()
- rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
- svcCtx.GrpcVerifyLimiter = limit.NewPeriodLimit(
- 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:verify:ut:"+testutil.UniqueId())
- srv := NewPermServer(svcCtx)
- ctx1 := withPeerIP(ctx, "10.9.8.7:30001")
- resp1, err1 := srv.VerifyToken(ctx1, &pb.VerifyTokenReq{AccessToken: "invalid"})
- require.NoError(t, err1, "首次放行:VerifyToken 对非法 token 只返回 Valid=false,不 error")
- require.NotNil(t, resp1)
- assert.False(t, resp1.Valid)
- // 同 IP 不同端口 → 必须被限流拦住。
- ctx2 := withPeerIP(ctx, "10.9.8.7:30002")
- _, err2 := srv.VerifyToken(ctx2, &pb.VerifyTokenReq{AccessToken: "whatever"})
- require.Error(t, err2)
- st2, _ := status.FromError(err2)
- assert.Equal(t, codes.ResourceExhausted, st2.Code(),
- "H-2:gRPC VerifyToken 必须受 IP 级限流保护,防止下游被当 token oracle 爆破")
- }
- // TC-0830: M-7 —— extractClientIP 对 "host:port" 必须剥成 host;
- // 缺失 peer 时返回 error,由上层决定降级到 unknown 桶。
- func TestExtractClientIP_StripsPort(t *testing.T) {
- addr, err := net.ResolveTCPAddr("tcp", "192.168.0.1:54321")
- require.NoError(t, err)
- ctx := peer.NewContext(context.Background(), &peer.Peer{Addr: addr})
- ip, err := extractClientIP(ctx)
- require.NoError(t, err)
- assert.Equal(t, "192.168.0.1", ip,
- "M-7:gRPC peer.Addr 必须剥成纯 host;保留端口会导致限流形同虚设")
- // 无 peer 的 context
- _, err2 := extractClientIP(context.Background())
- assert.Error(t, err2, "无 peer 时必须返回 error,让上层选择 fail-close 或降级到 unknown 桶")
- }
- // TC-0831: H-1 + M-7 —— gRPC RefreshToken 成功一次后,旧 refreshToken 立刻失效;
- // 换用同 IP 重放旧 token 必须返回 Unauthenticated("登录状态已失效"),
- // 而不是因端口变化绕过限流或因 CAS 失败被伪装成 500。
- func TestGrpcRefreshToken_CASInvalidatesOldToken(t *testing.T) {
- ctx := context.Background()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- cfg := testutil.GetTestConfig()
- rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
- // 放开限流以聚焦 CAS 正确性(quota 大)。
- svcCtx.GrpcRefreshLimiter = limit.NewPeriodLimit(
- 60, 100, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:refresh:cas:"+testutil.UniqueId())
- svcCtx.TokenOpLimiter = nil
- now := time.Now().Unix()
- uid := testutil.UniqueId()
- uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
- Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "n",
- Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
- Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- userId, _ := uRes.LastInsertId()
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
- rt, err := authHelper.GenerateRefreshToken(cfg.Auth.RefreshSecret, cfg.Auth.RefreshExpire, userId, "", 0)
- require.NoError(t, err)
- srv := NewPermServer(svcCtx)
- // 第一次成功刷新。
- ctx1 := withPeerIP(ctx, "172.16.0.1:11001")
- resp, err := srv.RefreshToken(ctx1, &pb.RefreshTokenReq{RefreshToken: rt})
- require.NoError(t, err)
- require.NotEmpty(t, resp.RefreshToken)
- // 用同一个旧 rt 重放,应当 Unauthenticated;
- // 注意:旧 token 里 tokenVersion=0,DB 已被 CAS 推到 1,所以 "claims.TokenVersion != ud.TokenVersion" 这一步就会拦住。
- // 端口换掉以确保不是限流在帮我们挡。
- ctx2 := withPeerIP(ctx, "172.16.0.1:11002")
- _, err = srv.RefreshToken(ctx2, &pb.RefreshTokenReq{RefreshToken: rt})
- require.Error(t, err, "H-1:旧 refreshToken 成功刷新一次后必须失效")
- st, _ := status.FromError(err)
- assert.Equal(t, codes.Unauthenticated, st.Code(),
- "旧 token 重放必须返回 Unauthenticated,不能是 Internal/ResourceExhausted")
- assert.Contains(t, st.Message(), "登录状态已失效")
- }
|