jwtauth_checkorder_audit_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. package middleware_test
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "time"
  10. "perms-system-server/internal/consts"
  11. "perms-system-server/internal/middleware"
  12. "perms-system-server/internal/model"
  13. productModel "perms-system-server/internal/model/product"
  14. productmemberModel "perms-system-server/internal/model/productmember"
  15. userModel "perms-system-server/internal/model/user"
  16. "perms-system-server/internal/response"
  17. "perms-system-server/internal/testutil"
  18. "github.com/stretchr/testify/assert"
  19. "github.com/stretchr/testify/require"
  20. )
  21. // TC-0749: L-B 修复回归 —— jwtauthMiddleware 必须优先判定 TokenVersion 失效,
  22. // 而不是 ProductStatus/MemberType。旧实现会在"产品已禁用"场景下先返回 403 ProductDisabled,
  23. // 使用户被强制退出时看到无关文案;修复后 TokenVersion 不一致应返回 401 "登录状态已失效,请重新登录"。
  24. func TestJwtAuthMiddleware_TokenVersionCheckedBeforeProductStatus(t *testing.T) {
  25. ctx := context.Background()
  26. conn := testutil.GetTestSqlConn()
  27. models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
  28. now := time.Now().Unix()
  29. // 1) 创建"已禁用"的产品
  30. pCode := "mw_ord_" + testutil.UniqueId()
  31. pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{
  32. Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s",
  33. Status: consts.StatusDisabled, CreateTime: now, UpdateTime: now,
  34. })
  35. require.NoError(t, err)
  36. pId, _ := pRes.LastInsertId()
  37. // 2) 创建启用中的用户,TokenVersion=5
  38. username := "mw_ord_u_" + testutil.UniqueId()
  39. uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
  40. Username: username, Password: "x", Nickname: "n",
  41. Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
  42. MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 5,
  43. CreateTime: now, UpdateTime: now,
  44. })
  45. require.NoError(t, err)
  46. userId, _ := uRes.LastInsertId()
  47. // 3) 该用户是禁用产品的 ADMIN 成员
  48. mRes, err := models.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
  49. ProductCode: pCode, UserId: userId, MemberType: consts.MemberTypeAdmin,
  50. Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
  51. })
  52. require.NoError(t, err)
  53. mId, _ := mRes.LastInsertId()
  54. t.Cleanup(func() {
  55. testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
  56. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  57. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  58. })
  59. m, _ := newTestMiddleware()
  60. // 携带 stale TokenVersion=3 的 access token(DB 是 5)+ 禁用产品 code
  61. tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
  62. TokenType: consts.TokenTypeAccess,
  63. UserId: userId,
  64. Username: username,
  65. ProductCode: pCode,
  66. TokenVersion: 3,
  67. })
  68. handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
  69. t.Fatal("should not reach handler")
  70. })
  71. req := httptest.NewRequest(http.MethodPost, "/test", nil)
  72. req.Header.Set("Authorization", "Bearer "+tokenStr)
  73. rr := httptest.NewRecorder()
  74. handler.ServeHTTP(rr, req)
  75. var body response.Body
  76. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
  77. assert.Equal(t, 401, body.Code,
  78. "L-B:TokenVersion 失配必须先于产品禁用被识别(返回 401 而非 403)")
  79. assert.Equal(t, "登录状态已失效,请重新登录", body.Msg,
  80. "L-B:文案必须是'登录状态已失效'而不是'该产品已被禁用',否则用户会被无关信息误导")
  81. }
  82. // TC-0750: L-B 修复回归 —— TokenVersion 匹配但产品被禁用,仍应返回 403 "该产品已被禁用"。
  83. // 保证修复未把所有场景都吞成 401。
  84. func TestJwtAuthMiddleware_ProductDisabledAfterVersionOk(t *testing.T) {
  85. ctx := context.Background()
  86. conn := testutil.GetTestSqlConn()
  87. models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
  88. now := time.Now().Unix()
  89. pCode := "mw_ord2_" + testutil.UniqueId()
  90. pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{
  91. Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s",
  92. Status: consts.StatusDisabled, CreateTime: now, UpdateTime: now,
  93. })
  94. require.NoError(t, err)
  95. pId, _ := pRes.LastInsertId()
  96. username := "mw_ord2_u_" + testutil.UniqueId()
  97. uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
  98. Username: username, Password: "x", Nickname: "n",
  99. Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
  100. MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 0,
  101. CreateTime: now, UpdateTime: now,
  102. })
  103. require.NoError(t, err)
  104. userId, _ := uRes.LastInsertId()
  105. mRes, err := models.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
  106. ProductCode: pCode, UserId: userId, MemberType: consts.MemberTypeAdmin,
  107. Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
  108. })
  109. require.NoError(t, err)
  110. mId, _ := mRes.LastInsertId()
  111. t.Cleanup(func() {
  112. testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
  113. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  114. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  115. })
  116. m, _ := newTestMiddleware()
  117. tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
  118. TokenType: consts.TokenTypeAccess,
  119. UserId: userId,
  120. Username: username,
  121. ProductCode: pCode,
  122. TokenVersion: 0,
  123. })
  124. handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
  125. t.Fatal("should not reach handler")
  126. })
  127. req := httptest.NewRequest(http.MethodPost, "/test", nil)
  128. req.Header.Set("Authorization", "Bearer "+tokenStr)
  129. rr := httptest.NewRecorder()
  130. handler.ServeHTTP(rr, req)
  131. var body response.Body
  132. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
  133. assert.Equal(t, 403, body.Code)
  134. assert.Equal(t, "该产品已被禁用", body.Msg)
  135. }
  136. // --- 鉴权优先级完整矩阵(L-B 延伸,TC-0754 ~ TC-0758)---
  137. // 代码中顺序: Username empty -> Status disabled -> TokenVersion mismatch -> ProductStatus -> MemberType。
  138. // 这 5 个用例用"同时踩两个坑"的组合方式, 严格断言哪个错误文案胜出, 形成鉴权优先级的冻结矩阵。
  139. // TC-0754: 用户已被删除 + TokenVersion 失配 -> 优先返回 401 "用户不存在或已被删除"。
  140. // 场景: 攻击者拿着 stale token + 账号已被删除, 服务端必须先识别出用户不存在而不是"登录已失效",
  141. // 否则会把"软删除"语义泄漏成"用户登出"从而引导攻击者再次尝试重登。
  142. func TestJwtAuthMiddleware_UserDeletedBeatsTokenVersion(t *testing.T) {
  143. ctx := context.Background()
  144. conn := testutil.GetTestSqlConn()
  145. models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
  146. now := time.Now().Unix()
  147. username := "mw_del_" + testutil.UniqueId()
  148. uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
  149. Username: username, Password: "x", Nickname: "n",
  150. Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
  151. MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 5,
  152. CreateTime: now, UpdateTime: now,
  153. })
  154. require.NoError(t, err)
  155. userId, _ := uRes.LastInsertId()
  156. require.NoError(t, models.SysUserModel.Delete(ctx, userId))
  157. m, _ := newTestMiddleware()
  158. tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
  159. TokenType: consts.TokenTypeAccess, UserId: userId, Username: username,
  160. TokenVersion: 1,
  161. })
  162. handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") })
  163. req := httptest.NewRequest(http.MethodPost, "/test", nil)
  164. req.Header.Set("Authorization", "Bearer "+tokenStr)
  165. rr := httptest.NewRecorder()
  166. handler.ServeHTTP(rr, req)
  167. var body response.Body
  168. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
  169. assert.Equal(t, 401, body.Code,
  170. "L-B 矩阵: Username empty 必须在 TokenVersion 之前裁决")
  171. assert.Equal(t, "用户不存在或已被删除", body.Msg,
  172. "L-B 矩阵: 用户被删除时文案不可退化成 '登录已失效',否则泄漏软删除语义")
  173. }
  174. // TC-0755: 账号被冻结 + TokenVersion 失配 + 产品被禁用 -> 胜出应是 403 "账号已被冻结"。
  175. // 三重 failing condition 叠加, 验证"账号级"问题比"会话级"(TokenVersion) 和"产品级"(ProductStatus) 优先级更高。
  176. func TestJwtAuthMiddleware_FrozenBeatsEverything(t *testing.T) {
  177. ctx := context.Background()
  178. conn := testutil.GetTestSqlConn()
  179. models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
  180. now := time.Now().Unix()
  181. pCode := "mw_fz_" + testutil.UniqueId()
  182. pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{
  183. Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s",
  184. Status: consts.StatusDisabled, CreateTime: now, UpdateTime: now,
  185. })
  186. require.NoError(t, err)
  187. pId, _ := pRes.LastInsertId()
  188. username := "mw_fz_u_" + testutil.UniqueId()
  189. uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
  190. Username: username, Password: "x", Nickname: "n",
  191. Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
  192. MustChangePassword: 2, Status: consts.StatusDisabled, TokenVersion: 9,
  193. CreateTime: now, UpdateTime: now,
  194. })
  195. require.NoError(t, err)
  196. userId, _ := uRes.LastInsertId()
  197. mRes, err := models.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
  198. ProductCode: pCode, UserId: userId, MemberType: consts.MemberTypeAdmin,
  199. Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
  200. })
  201. require.NoError(t, err)
  202. mId, _ := mRes.LastInsertId()
  203. t.Cleanup(func() {
  204. testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
  205. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  206. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  207. })
  208. m, _ := newTestMiddleware()
  209. tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
  210. TokenType: consts.TokenTypeAccess, UserId: userId, Username: username,
  211. ProductCode: pCode, TokenVersion: 1,
  212. })
  213. handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") })
  214. req := httptest.NewRequest(http.MethodPost, "/test", nil)
  215. req.Header.Set("Authorization", "Bearer "+tokenStr)
  216. rr := httptest.NewRecorder()
  217. handler.ServeHTTP(rr, req)
  218. var body response.Body
  219. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
  220. assert.Equal(t, 403, body.Code,
  221. "L-B 矩阵: 账号冻结(403) 胜出, 而非 TokenVersion(401) 或 ProductStatus(403/禁用)")
  222. assert.Equal(t, "账号已被冻结", body.Msg,
  223. "L-B 矩阵: 冻结文案必须先于 '登录已失效'/'产品禁用' 返回给客户端")
  224. }
  225. // TC-0756: TokenVersion OK + 产品启用 + 非超管 + MemberType 为空 -> 403 "您已不是该产品的有效成员"。
  226. // 场景: 用户曾是产品成员, 后被移除, 但老 token 未过期; 本用例保证"移除成员"的写路径会被读路径识别。
  227. func TestJwtAuthMiddleware_NonMemberRejected(t *testing.T) {
  228. ctx := context.Background()
  229. conn := testutil.GetTestSqlConn()
  230. models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
  231. now := time.Now().Unix()
  232. pCode := "mw_nm_" + testutil.UniqueId()
  233. pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{
  234. Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s",
  235. Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
  236. })
  237. require.NoError(t, err)
  238. pId, _ := pRes.LastInsertId()
  239. username := "mw_nm_u_" + testutil.UniqueId()
  240. uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
  241. Username: username, Password: "x", Nickname: "n",
  242. Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
  243. MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 0,
  244. CreateTime: now, UpdateTime: now,
  245. })
  246. require.NoError(t, err)
  247. userId, _ := uRes.LastInsertId()
  248. t.Cleanup(func() {
  249. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  250. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  251. })
  252. m, _ := newTestMiddleware()
  253. tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
  254. TokenType: consts.TokenTypeAccess, UserId: userId, Username: username,
  255. ProductCode: pCode, TokenVersion: 0,
  256. })
  257. handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") })
  258. req := httptest.NewRequest(http.MethodPost, "/test", nil)
  259. req.Header.Set("Authorization", "Bearer "+tokenStr)
  260. rr := httptest.NewRecorder()
  261. handler.ServeHTTP(rr, req)
  262. var body response.Body
  263. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
  264. assert.Equal(t, 403, body.Code)
  265. assert.Equal(t, "您已不是该产品的有效成员", body.Msg,
  266. "L-B 矩阵: MemberType 空 + 非超管 + 产品启用 必须精确命中'不是有效成员'文案")
  267. }
  268. // TC-0757: 超级管理员 + ProductCode 携带 + MemberType 空 -> 正常放行。
  269. // 超管"旁路"分支不能被移除, 否则超管在产品上下文会被错误踢出。
  270. func TestJwtAuthMiddleware_SuperAdminBypassesMemberCheck(t *testing.T) {
  271. ctx := context.Background()
  272. conn := testutil.GetTestSqlConn()
  273. models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
  274. now := time.Now().Unix()
  275. pCode := "mw_sa_" + testutil.UniqueId()
  276. pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{
  277. Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s",
  278. Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
  279. })
  280. require.NoError(t, err)
  281. pId, _ := pRes.LastInsertId()
  282. username := "mw_sa_u_" + testutil.UniqueId()
  283. uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
  284. Username: username, Password: "x", Nickname: "n",
  285. Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminYes,
  286. MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 0,
  287. CreateTime: now, UpdateTime: now,
  288. })
  289. require.NoError(t, err)
  290. userId, _ := uRes.LastInsertId()
  291. t.Cleanup(func() {
  292. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  293. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  294. })
  295. m, _ := newTestMiddleware()
  296. tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
  297. TokenType: consts.TokenTypeAccess, UserId: userId, Username: username,
  298. ProductCode: pCode, TokenVersion: 0,
  299. })
  300. var reached bool
  301. handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
  302. reached = true
  303. w.WriteHeader(http.StatusOK)
  304. })
  305. req := httptest.NewRequest(http.MethodPost, "/test", nil)
  306. req.Header.Set("Authorization", "Bearer "+tokenStr)
  307. rr := httptest.NewRecorder()
  308. handler.ServeHTTP(rr, req)
  309. assert.Equal(t, http.StatusOK, rr.Code, "L-B 矩阵: 超管必须放行, 即使在产品上下文无 MemberType")
  310. assert.True(t, reached, "请求必须到达业务 handler")
  311. }
  312. // TC-0758: 无 ProductCode 时, Frozen 用户 + TokenVersion 失配 -> 403 "账号已被冻结"。
  313. // 验证即使不走产品相关分支, Status 检查仍先于 TokenVersion 裁决。
  314. func TestJwtAuthMiddleware_FrozenBeatsTokenVersionNoProduct(t *testing.T) {
  315. ctx := context.Background()
  316. conn := testutil.GetTestSqlConn()
  317. models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
  318. now := time.Now().Unix()
  319. username := "mw_fz2_u_" + testutil.UniqueId()
  320. uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
  321. Username: username, Password: "x", Nickname: "n",
  322. Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
  323. MustChangePassword: 2, Status: consts.StatusDisabled, TokenVersion: 7,
  324. CreateTime: now, UpdateTime: now,
  325. })
  326. require.NoError(t, err)
  327. userId, _ := uRes.LastInsertId()
  328. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  329. m, _ := newTestMiddleware()
  330. tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
  331. TokenType: consts.TokenTypeAccess, UserId: userId, Username: username,
  332. TokenVersion: 0,
  333. })
  334. handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") })
  335. req := httptest.NewRequest(http.MethodPost, "/test", nil)
  336. req.Header.Set("Authorization", "Bearer "+tokenStr)
  337. rr := httptest.NewRecorder()
  338. handler.ServeHTTP(rr, req)
  339. var body response.Body
  340. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
  341. assert.Equal(t, 403, body.Code)
  342. assert.Equal(t, "账号已被冻结", body.Msg)
  343. }