grpcHttpRotateInterop_r11_5_audit_test.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. package server
  2. import (
  3. "context"
  4. "database/sql"
  5. "testing"
  6. "time"
  7. authHelper "perms-system-server/internal/logic/auth"
  8. pubLogic "perms-system-server/internal/logic/pub"
  9. userModel "perms-system-server/internal/model/user"
  10. "perms-system-server/internal/svc"
  11. "perms-system-server/internal/testutil"
  12. "perms-system-server/internal/types"
  13. "perms-system-server/pb"
  14. "github.com/stretchr/testify/assert"
  15. "github.com/stretchr/testify/require"
  16. "google.golang.org/grpc/codes"
  17. "google.golang.org/grpc/status"
  18. )
  19. // insertPermServerTestUser:server 包本地的 user 插入 helper。包内原本没有公共 helper,
  20. // 为了不把实现细节泄漏到 testutil(避免被其他包共用造成耦合),这里保留包内。
  21. func insertPermServerTestUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext,
  22. username, password string, status, isSuperAdmin int64) (int64, func()) {
  23. t.Helper()
  24. conn := testutil.GetTestSqlConn()
  25. now := time.Now().Unix()
  26. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  27. Username: username,
  28. Password: testutil.HashPassword(password),
  29. Nickname: username,
  30. Avatar: sql.NullString{},
  31. Email: username + "@ut.local",
  32. Phone: "13800000000",
  33. IsSuperAdmin: isSuperAdmin,
  34. MustChangePassword: 2,
  35. Status: status,
  36. CreateTime: now,
  37. UpdateTime: now,
  38. })
  39. require.NoError(t, err)
  40. id, err := res.LastInsertId()
  41. require.NoError(t, err)
  42. return id, func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) }
  43. }
  44. // ---------------------------------------------------------------------------
  45. // 覆盖目标:审计 L-R11-5 —— HTTP RefreshToken 与 gRPC RefreshToken 共用
  46. // authHelper.RotateRefreshToken,**签发出的新 refreshToken 必须可以互换使用**。
  47. // 这是"helper 共享"最锋利的回归面:一旦某一侧背后悄悄改回自己的版本推进/签名流程,
  48. // 两边发出的 token 会在 tokenVersion / claims 结构上漂移,下一次交叉刷新会立刻 401。
  49. // ---------------------------------------------------------------------------
  50. // TC-1070: L-R11-5 —— HTTP 签出的 refreshToken 必须能被 gRPC RefreshToken 无缝续签。
  51. func TestRefreshToken_HTTPIssuedTokenAcceptedByGrpc(t *testing.T) {
  52. ctx := context.Background()
  53. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  54. svcCtx.TokenOpLimiter = nil
  55. svcCtx.GrpcRefreshLimiter = nil
  56. username := "r11_5_interop_h2g_" + testutil.UniqueId()
  57. userId, cleanup := insertPermServerTestUser(t, ctx, svcCtx, username, "SomePass123", 1, 2)
  58. t.Cleanup(cleanup)
  59. rtV0, err := authHelper.GenerateRefreshToken(
  60. svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
  61. userId, "", 0,
  62. )
  63. require.NoError(t, err)
  64. httpResp, err := pubLogic.NewRefreshTokenLogic(ctx, svcCtx).
  65. RefreshToken(&types.RefreshTokenReq{Authorization: "Bearer " + rtV0})
  66. require.NoError(t, err, "HTTP 首刷应成功,DB tokenVersion 0 → 1")
  67. require.NotNil(t, httpResp)
  68. require.NotEmpty(t, httpResp.RefreshToken)
  69. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  70. require.NoError(t, err)
  71. assert.Equal(t, int64(1), u.TokenVersion)
  72. // HTTP 新发的 refreshToken (claims.TokenVersion=1) 直接喂给 gRPC。
  73. svcCtx.UserDetailsLoader.Clean(ctx, userId)
  74. grpcResp, err := NewPermServer(svcCtx).RefreshToken(
  75. ctx, &pb.RefreshTokenReq{RefreshToken: httpResp.RefreshToken})
  76. require.NoError(t, err,
  77. "L-R11-5 契约:HTTP 发的 refreshToken 必须被 gRPC 无缝接收;"+
  78. "若 gRPC 走自己的版本比对/签名链,这里会 Unauthenticated")
  79. assert.NotEmpty(t, grpcResp.RefreshToken)
  80. assert.NotEmpty(t, grpcResp.AccessToken)
  81. u2, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  82. require.NoError(t, err)
  83. assert.Equal(t, int64(2), u2.TokenVersion,
  84. "L-R11-5:gRPC 续签后 DB tokenVersion 必须 +1;两条路径共用同一 CAS 语义")
  85. }
  86. // TC-1071: L-R11-5 —— gRPC 签出的 refreshToken 必须能被 HTTP RefreshToken 无缝续签。
  87. // 镜像 TC-1070 的反方向,两侧都 pin 死才能防"helper 只有一侧真在调"的回退。
  88. func TestRefreshToken_GrpcIssuedTokenAcceptedByHttp(t *testing.T) {
  89. ctx := context.Background()
  90. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  91. svcCtx.TokenOpLimiter = nil
  92. svcCtx.GrpcRefreshLimiter = nil
  93. username := "r11_5_interop_g2h_" + testutil.UniqueId()
  94. userId, cleanup := insertPermServerTestUser(t, ctx, svcCtx, username, "SomePass123", 1, 2)
  95. t.Cleanup(cleanup)
  96. rtV0, err := authHelper.GenerateRefreshToken(
  97. svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
  98. userId, "", 0,
  99. )
  100. require.NoError(t, err)
  101. grpcResp, err := NewPermServer(svcCtx).RefreshToken(
  102. ctx, &pb.RefreshTokenReq{RefreshToken: rtV0})
  103. require.NoError(t, err, "gRPC 首刷应成功,DB tokenVersion 0 → 1")
  104. require.NotEmpty(t, grpcResp.RefreshToken)
  105. u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  106. require.NoError(t, err)
  107. assert.Equal(t, int64(1), u.TokenVersion)
  108. svcCtx.UserDetailsLoader.Clean(ctx, userId)
  109. httpResp, err := pubLogic.NewRefreshTokenLogic(ctx, svcCtx).
  110. RefreshToken(&types.RefreshTokenReq{Authorization: "Bearer " + grpcResp.RefreshToken})
  111. require.NoError(t, err, "L-R11-5:gRPC 发的 refreshToken 必须被 HTTP 无缝接收")
  112. require.NotNil(t, httpResp)
  113. u2, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  114. require.NoError(t, err)
  115. assert.Equal(t, int64(2), u2.TokenVersion,
  116. "L-R11-5:HTTP 续签后 DB tokenVersion 必须 +1")
  117. }
  118. // TC-1072: L-R11-5 —— gRPC RefreshToken 对 ErrTokenVersionMismatch 的映射契约未回归
  119. // 这里不再测"两次并发 CAS 只有一个赢"(已由 TestRefreshToken_ConcurrentSameToken_SingleWinner
  120. // 与 TestGrpcRefreshToken_ReplayOldToken 覆盖),而是显式钉死:一旦 helper 返回
  121. // ErrTokenVersionMismatch,gRPC 侧必须走 codes.Unauthenticated 而不是 Internal。
  122. func TestGrpcRefreshToken_ReplayedTokenMapsUnauthenticated(t *testing.T) {
  123. ctx := context.Background()
  124. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  125. svcCtx.TokenOpLimiter = nil
  126. svcCtx.GrpcRefreshLimiter = nil
  127. username := "r11_5_replay_" + testutil.UniqueId()
  128. userId, cleanup := insertPermServerTestUser(t, ctx, svcCtx, username, "SomePass123", 1, 2)
  129. t.Cleanup(cleanup)
  130. rtV0, err := authHelper.GenerateRefreshToken(
  131. svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
  132. userId, "", 0,
  133. )
  134. require.NoError(t, err)
  135. // 首次:成功,tokenVersion 0 → 1
  136. _, err = NewPermServer(svcCtx).RefreshToken(ctx, &pb.RefreshTokenReq{RefreshToken: rtV0})
  137. require.NoError(t, err)
  138. // 第二次重放同一个旧 rtV0:claims.TokenVersion=0 但 DB=1。
  139. // Logic 上游 `claims.TokenVersion != ud.TokenVersion` 会先拦住并走 Unauthenticated,
  140. // 但本 TC 要确认的是:**即使未来有人把上游校验逻辑拿掉**,helper 的 CAS 依然兜底,且 gRPC
  141. // 侧仍映射到 codes.Unauthenticated(而非 Internal)。
  142. svcCtx.UserDetailsLoader.Clean(ctx, userId)
  143. _, err = NewPermServer(svcCtx).RefreshToken(ctx, &pb.RefreshTokenReq{RefreshToken: rtV0})
  144. require.Error(t, err)
  145. st, _ := status.FromError(err)
  146. assert.Equal(t, codes.Unauthenticated, st.Code(),
  147. "L-R11-5:gRPC 侧 ErrTokenVersionMismatch 必须 codes.Unauthenticated;"+
  148. "若漂移到 Internal,接入方会当成系统故障告警而非会话失效")
  149. assert.Contains(t, st.Message(), "失效")
  150. }