changePasswordToctou_audit_test.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. package auth
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "testing"
  7. "time"
  8. "perms-system-server/internal/consts"
  9. "perms-system-server/internal/loaders"
  10. "perms-system-server/internal/middleware"
  11. userModel "perms-system-server/internal/model/user"
  12. "perms-system-server/internal/response"
  13. "perms-system-server/internal/svc"
  14. "perms-system-server/internal/testutil"
  15. "perms-system-server/internal/testutil/mocks"
  16. "perms-system-server/internal/types"
  17. "github.com/stretchr/testify/assert"
  18. "github.com/stretchr/testify/require"
  19. "go.uber.org/mock/gomock"
  20. "golang.org/x/crypto/bcrypt"
  21. )
  22. // ---------------------------------------------------------------------------
  23. // 覆盖目标:H-R11-1 的 E2E 边界契约 —— 改密的 "400 原密码错误" 与 "409 被其他会话抢改"
  24. // 两条分支必须互不污染。
  25. //
  26. // H-R11-1 的核心是把 `UpdatePassword` 的乐观锁 expected 从内部自取改为外层 FindOne 透传,
  27. // 并在 Logic 层把 `ErrUpdateConflict` 显式映射 409。这里补一条"正常串行"回归:
  28. // T0 ChangePassword(old=P0, new=P1) → 首改成功
  29. // T1 ChangePassword(old=P0, new=P2) → 旧密码已失配,必须 400"原密码错误";
  30. // 绝不能因为"外层快照已陈旧"之类的原因落到 409 分支。
  31. //
  32. // 该契约直接护栏 H-R11-1 的 ErrUpdateConflict 映射逻辑不被误写成"吞掉所有错误都回 409"。
  33. // 底层 CAS 正确性已在 model 层 TestSysUserModel_UpdatePassword_StaleExpectedUpdateTime_Conflict /
  34. // ConcurrentProfileWrite_BlocksPasswordUpdate 两个用例闭合;Logic→Model 的签名传参契约
  35. // 则由 TestChangePassword_UpdateConflict_Maps409(mock UpdatePassword(..., 1000) 必须收到
  36. // user.UpdateTime=1000)钉死。所以这里只补 400/409 分支隔离。
  37. // ---------------------------------------------------------------------------
  38. func insertToctouUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext,
  39. username, plainPwd string) (int64, func()) {
  40. t.Helper()
  41. now := time.Now().Unix()
  42. hashed, err := bcrypt.GenerateFromPassword([]byte(plainPwd), bcrypt.DefaultCost)
  43. require.NoError(t, err)
  44. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  45. Username: username,
  46. Password: string(hashed),
  47. Nickname: "toctou",
  48. Avatar: sql.NullString{},
  49. Email: username + "@test.com",
  50. Phone: "13800000000",
  51. Remark: "",
  52. DeptId: 0,
  53. IsSuperAdmin: 2,
  54. MustChangePassword: 2,
  55. Status: 1,
  56. CreateTime: now,
  57. UpdateTime: now,
  58. })
  59. require.NoError(t, err)
  60. id, _ := res.LastInsertId()
  61. cleanup := func() {
  62. testutil.CleanTable(ctx, testutil.GetTestSqlConn(), "`sys_user`", id)
  63. }
  64. return id, cleanup
  65. }
  66. // TC-1042: H-R11-1 E2E —— 400 vs 409 分支隔离:旧密码失配必须 400,绝不能误落 409
  67. func TestChangePassword_E2E_SecondCallWithOldPwd_Maps400(t *testing.T) {
  68. ctx := context.Background()
  69. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  70. svcCtx.TokenOpLimiter = nil
  71. oldPwd := "Oldpass123"
  72. username := "toctou_seq_" + testutil.UniqueId()
  73. userId, cleanup := insertToctouUser(t, ctx, svcCtx, username, oldPwd)
  74. t.Cleanup(cleanup)
  75. lctx := middleware.WithUserDetails(context.Background(),
  76. &loaders.UserDetails{UserId: userId, Username: username, Status: 1})
  77. require.NoError(t,
  78. NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
  79. OldPassword: oldPwd, NewPassword: "NewpassX_11",
  80. }),
  81. "首改必须成功")
  82. err := NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
  83. OldPassword: oldPwd, NewPassword: "NewpassY_22",
  84. })
  85. require.Error(t, err)
  86. var ce *response.CodeError
  87. require.True(t, errors.As(err, &ce))
  88. assert.Equal(t, 400, ce.Code(),
  89. "H-R11-1:旧密码已失配应 400'原密码错误';不得因 ErrUpdateConflict 映射被误回 409")
  90. assert.Contains(t, ce.Error(), "原密码错误")
  91. // DB 终态:Password 是首改成功的 NewpassX_11,tokenVersion 恰好 1(而不是 2)。
  92. got, err := svcCtx.SysUserModel.FindOne(ctx, userId)
  93. require.NoError(t, err)
  94. assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(got.Password), []byte("NewpassX_11")))
  95. assert.Equal(t, int64(1), got.TokenVersion,
  96. "H-R11-1:首改成功递增 1;第二次因 400 未进入 UpdatePassword,tokenVersion 必须仍是 1")
  97. }
  98. // TC-1043: H-R11-1 —— UpdatePassword 签名护栏(mock 驱动):
  99. // 签名一旦回退(例如 username 再次被内部 FindOne 取而非外层透传),整个链路会编译失败;
  100. // 但契约层面的"必须透传外层 snapshot 的 UpdateTime"更细致:Logic 必须把 FindOne 返回的
  101. // snapshot.UpdateTime 原样交给 UpdatePassword,不得自己算 time.Now() 或重新 FindOne。
  102. // 这里用 mock 钉死该契约:FindOne 返回 UpdateTime=4242,UpdatePassword 必须收到 4242。
  103. func TestChangePassword_ForwardsSnapshotUpdateTime(t *testing.T) {
  104. // 注:此契约已由既有 TestChangePassword_UpdateConflict_Maps409(UpdateTime=1000)覆盖,
  105. // 这里再以另一组数值(4242)做"反证哨兵",若 DEV 不小心硬编码常量/写死 time.Now,
  106. // 两组数值会同时失败,快速定位。
  107. t.Run("expected=4242", func(t *testing.T) { runSnapshotForwardCase(t, 4242) })
  108. t.Run("expected=9876543210", func(t *testing.T) { runSnapshotForwardCase(t, 9876543210) })
  109. }
  110. func runSnapshotForwardCase(t *testing.T, expectedUpdateTime int64) {
  111. ctrl := gomock.NewController(t)
  112. t.Cleanup(ctrl.Finish)
  113. const userId = int64(17)
  114. oldPwd := "Oldpass123"
  115. newPwd := "Newpass456"
  116. hashed, err := bcrypt.GenerateFromPassword([]byte(oldPwd), bcrypt.DefaultCost)
  117. require.NoError(t, err)
  118. mockUser := mocks.NewMockSysUserModel(ctrl)
  119. mockUser.EXPECT().FindOne(gomock.Any(), userId).
  120. Return(&userModel.SysUser{
  121. Id: userId,
  122. Username: "snap_subject",
  123. Password: string(hashed),
  124. Status: 1,
  125. UpdateTime: expectedUpdateTime,
  126. }, nil)
  127. // 合同:UpdatePassword 的第 6 个参数必须与 FindOne 返回的 UpdateTime 字面相等。
  128. mockUser.EXPECT().
  129. UpdatePassword(gomock.Any(), userId, "snap_subject", gomock.Any(),
  130. int64(consts.MustChangePasswordNo), expectedUpdateTime).
  131. Return(nil)
  132. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
  133. ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
  134. require.NoError(t, NewChangePasswordLogic(ctx, svcCtx).ChangePassword(
  135. &types.ChangePasswordReq{OldPassword: oldPwd, NewPassword: newPwd}))
  136. }