updatePasswordToctou_audit_test.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. package user_test
  2. import (
  3. "context"
  4. "testing"
  5. "time"
  6. "perms-system-server/internal/model/user"
  7. "perms-system-server/internal/testutil"
  8. "github.com/stretchr/testify/assert"
  9. "github.com/stretchr/testify/require"
  10. )
  11. // ---------------------------------------------------------------------------
  12. // 覆盖目标:审计 H-R11-1 —— UpdatePassword 的 expectedUpdateTime 必须由调用方透传,
  13. // Model 层不得再内部 FindOne 自对齐 CAS;否则两个会话"都知道旧密码"时第二个会把第一个
  14. // 紧急修改过的新密码盖回。此文件从 Model 层钉死三条关键契约:
  15. // 1) 签名强制 6 参 (id, username, password, mustChangePassword, expectedUpdateTime);
  16. // 2) 外层持有的陈旧 updateTime 传入 → ErrUpdateConflict(并发写入已让 DB 的 updateTime 推进);
  17. // 3) 正常路径 expectedUpdateTime 匹配时落盘成功,password / tokenVersion 正确持久化。
  18. // ---------------------------------------------------------------------------
  19. // TC-1039: H-R11-1 —— 并发场景下外层快照的 expectedUpdateTime 已被他人推进,CAS 必须失败
  20. func TestSysUserModel_UpdatePassword_StaleExpectedUpdateTime_Conflict(t *testing.T) {
  21. ctx := context.Background()
  22. m, conn := newModel(t)
  23. username := "hr111_stale_" + testutil.UniqueId()
  24. data := newTestSysUser(username, 1)
  25. res, err := m.Insert(ctx, data)
  26. require.NoError(t, err)
  27. id, err := res.LastInsertId()
  28. require.NoError(t, err)
  29. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  30. // 外层 Session A 观测到的 updateTime(会校验旧密码时一起拿到)
  31. snapshotA, err := m.FindOne(ctx, id)
  32. require.NoError(t, err)
  33. snapshotAUpdateTime := snapshotA.UpdateTime
  34. // sys_user.updateTime 精度到秒,确保 Session B 提交的 UPDATE 严格推进 updateTime;
  35. // 否则同秒写回值与 snapshotAUpdateTime 相同,CAS 仍然匹配,无法复现 TOCTOU。
  36. time.Sleep(1100 * time.Millisecond)
  37. // Session B("设备 B 紧急改密 P2")抢先基于 snapshotA 成功完成一次 CAS
  38. require.NoError(t,
  39. m.UpdatePassword(ctx, id, username, "H_P2", 1, snapshotAUpdateTime),
  40. "Session B 基于快照 A 的 updateTime 抢先完成 CAS,应当成功")
  41. // 现在 DB 的 updateTime 已经不是 snapshotAUpdateTime。
  42. // Session A(持有旧密码 P0、已校验过旧密码)再用**同一份**旧 snapshot 的 updateTime
  43. // 去改密 P1,CAS 必须失败,否则 P2 会被 P1 覆盖(H-R11-1 TOCTOU)。
  44. err = m.UpdatePassword(ctx, id, username, "H_P1_to_cover_P2", 1, snapshotAUpdateTime)
  45. require.ErrorIs(t, err, user.ErrUpdateConflict,
  46. "H-R11-1:expectedUpdateTime 必须是外层快照;Session B 已推进时,Session A 的改密 CAS 必须失败")
  47. // DB 终态保持为 Session B 的 H_P2,不被 Session A 覆盖
  48. got, err := m.FindOne(ctx, id)
  49. require.NoError(t, err)
  50. assert.Equal(t, "H_P2", got.Password,
  51. "H-R11-1:TOCTOU 被关闭后,DB 终态必须是后到而胜出的那一方,不得被旧快照覆盖")
  52. }
  53. // TC-1040: H-R11-1 —— 正常路径 expectedUpdateTime 匹配时 UpdatePassword 落盘并递增 tokenVersion
  54. func TestSysUserModel_UpdatePassword_HappyPath_ExplicitExpectedUpdateTime(t *testing.T) {
  55. ctx := context.Background()
  56. m, conn := newModel(t)
  57. username := "hr111_ok_" + testutil.UniqueId()
  58. data := newTestSysUser(username, 1)
  59. res, err := m.Insert(ctx, data)
  60. require.NoError(t, err)
  61. id, err := res.LastInsertId()
  62. require.NoError(t, err)
  63. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  64. orig, err := m.FindOne(ctx, id)
  65. require.NoError(t, err)
  66. origTV := orig.TokenVersion
  67. time.Sleep(1100 * time.Millisecond)
  68. require.NoError(t,
  69. m.UpdatePassword(ctx, id, username, "H_NEW", 0, orig.UpdateTime),
  70. "H-R11-1:expectedUpdateTime 与 DB 当前 updateTime 一致时必须成功")
  71. got, err := m.FindOne(ctx, id)
  72. require.NoError(t, err)
  73. assert.Equal(t, "H_NEW", got.Password)
  74. assert.Equal(t, int64(0), got.MustChangePassword)
  75. assert.Equal(t, origTV+1, got.TokenVersion,
  76. "H-R11-1:UpdatePassword 必须递增 tokenVersion 以注销旧会话")
  77. assert.Greater(t, got.UpdateTime, orig.UpdateTime,
  78. "H-R11-1:updateTime 必须推进以支撑下一次 CAS")
  79. }
  80. // TC-1041: H-R11-1 —— 同一行被并发修改(如 UpdateProfile 改了昵称)之后,UpdatePassword 的 CAS 必须失败
  81. // 覆盖"任何修改 sys_user 行的并发写入都会触发 ErrUpdateConflict"这一更严的契约:
  82. // 不仅是另一次改密可以"偷走"本次;改昵称/解冻/任何推进 updateTime 的操作也必须把本次改密拦住。
  83. func TestSysUserModel_UpdatePassword_ConcurrentProfileWrite_BlocksPasswordUpdate(t *testing.T) {
  84. ctx := context.Background()
  85. m, conn := newModel(t)
  86. username := "hr111_prof_" + testutil.UniqueId()
  87. data := newTestSysUser(username, 1)
  88. res, err := m.Insert(ctx, data)
  89. require.NoError(t, err)
  90. id, err := res.LastInsertId()
  91. require.NoError(t, err)
  92. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  93. snapshot, err := m.FindOne(ctx, id)
  94. require.NoError(t, err)
  95. // sys_user.updateTime 秒级,sleep 以确保 UpdateProfile 的 UPDATE 真的推进
  96. time.Sleep(1100 * time.Millisecond)
  97. // Session B 改了昵称(完全合法的场景:管理员在用户"修改密码"弹窗打开的同一时刻修了昵称)
  98. require.NoError(t,
  99. m.UpdateProfile(ctx, id, username,
  100. "new_nick", snapshot.Email, snapshot.Phone, snapshot.Remark,
  101. snapshot.DeptId, snapshot.Status, false, snapshot.UpdateTime),
  102. "UpdateProfile 旁路已成功执行")
  103. // Session A 仍然基于 snapshot.UpdateTime 改密 —— 必须被 CAS 拦住
  104. err = m.UpdatePassword(ctx, id, username, "H_LOST", 1, snapshot.UpdateTime)
  105. require.ErrorIs(t, err, user.ErrUpdateConflict,
  106. "H-R11-1:任何改动(含改昵称)都推进 updateTime;基于旧快照的改密必须被 CAS 拦住")
  107. got, err := m.FindOne(ctx, id)
  108. require.NoError(t, err)
  109. assert.Equal(t, snapshot.Password, got.Password, "Password 必须保持原值,未被 Session A 覆盖")
  110. assert.Equal(t, "new_nick", got.Nickname, "Profile 写入必须成功落盘")
  111. }