|
@@ -157,3 +157,244 @@ func TestJwtAuthMiddleware_ProductDisabledAfterVersionOk(t *testing.T) {
|
|
|
assert.Equal(t, 403, body.Code)
|
|
assert.Equal(t, 403, body.Code)
|
|
|
assert.Equal(t, "该产品已被禁用", body.Msg)
|
|
assert.Equal(t, "该产品已被禁用", body.Msg)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// --- 鉴权优先级完整矩阵(L-B 延伸,TC-0754 ~ TC-0758)---
|
|
|
|
|
+// 代码中顺序: Username empty -> Status disabled -> TokenVersion mismatch -> ProductStatus -> MemberType。
|
|
|
|
|
+// 这 5 个用例用"同时踩两个坑"的组合方式, 严格断言哪个错误文案胜出, 形成鉴权优先级的冻结矩阵。
|
|
|
|
|
+
|
|
|
|
|
+// TC-0754: 用户已被删除 + TokenVersion 失配 -> 优先返回 401 "用户不存在或已被删除"。
|
|
|
|
|
+// 场景: 攻击者拿着 stale token + 账号已被删除, 服务端必须先识别出用户不存在而不是"登录已失效",
|
|
|
|
|
+// 否则会把"软删除"语义泄漏成"用户登出"从而引导攻击者再次尝试重登。
|
|
|
|
|
+func TestJwtAuthMiddleware_UserDeletedBeatsTokenVersion(t *testing.T) {
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ conn := testutil.GetTestSqlConn()
|
|
|
|
|
+ models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
|
|
|
|
|
+ now := time.Now().Unix()
|
|
|
|
|
+
|
|
|
|
|
+ username := "mw_del_" + testutil.UniqueId()
|
|
|
|
|
+ uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
|
|
|
|
|
+ Username: username, Password: "x", Nickname: "n",
|
|
|
|
|
+ Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
|
|
|
|
|
+ MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 5,
|
|
|
|
|
+ CreateTime: now, UpdateTime: now,
|
|
|
|
|
+ })
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ userId, _ := uRes.LastInsertId()
|
|
|
|
|
+ require.NoError(t, models.SysUserModel.Delete(ctx, userId))
|
|
|
|
|
+
|
|
|
|
|
+ m, _ := newTestMiddleware()
|
|
|
|
|
+ tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
|
|
|
|
|
+ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username,
|
|
|
|
|
+ TokenVersion: 1,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") })
|
|
|
|
|
+ req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
|
|
|
+ req.Header.Set("Authorization", "Bearer "+tokenStr)
|
|
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
|
|
+ handler.ServeHTTP(rr, req)
|
|
|
|
|
+
|
|
|
|
|
+ var body response.Body
|
|
|
|
|
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
|
|
|
|
+ assert.Equal(t, 401, body.Code,
|
|
|
|
|
+ "L-B 矩阵: Username empty 必须在 TokenVersion 之前裁决")
|
|
|
|
|
+ assert.Equal(t, "用户不存在或已被删除", body.Msg,
|
|
|
|
|
+ "L-B 矩阵: 用户被删除时文案不可退化成 '登录已失效',否则泄漏软删除语义")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TC-0755: 账号被冻结 + TokenVersion 失配 + 产品被禁用 -> 胜出应是 403 "账号已被冻结"。
|
|
|
|
|
+// 三重 failing condition 叠加, 验证"账号级"问题比"会话级"(TokenVersion) 和"产品级"(ProductStatus) 优先级更高。
|
|
|
|
|
+func TestJwtAuthMiddleware_FrozenBeatsEverything(t *testing.T) {
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ conn := testutil.GetTestSqlConn()
|
|
|
|
|
+ models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
|
|
|
|
|
+ now := time.Now().Unix()
|
|
|
|
|
+
|
|
|
|
|
+ pCode := "mw_fz_" + testutil.UniqueId()
|
|
|
|
|
+ pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{
|
|
|
|
|
+ Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s",
|
|
|
|
|
+ Status: consts.StatusDisabled, CreateTime: now, UpdateTime: now,
|
|
|
|
|
+ })
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ pId, _ := pRes.LastInsertId()
|
|
|
|
|
+
|
|
|
|
|
+ username := "mw_fz_u_" + testutil.UniqueId()
|
|
|
|
|
+ uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
|
|
|
|
|
+ Username: username, Password: "x", Nickname: "n",
|
|
|
|
|
+ Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
|
|
|
|
|
+ MustChangePassword: 2, Status: consts.StatusDisabled, TokenVersion: 9,
|
|
|
|
|
+ CreateTime: now, UpdateTime: now,
|
|
|
|
|
+ })
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ userId, _ := uRes.LastInsertId()
|
|
|
|
|
+
|
|
|
|
|
+ mRes, err := models.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
|
|
|
|
|
+ ProductCode: pCode, UserId: userId, MemberType: consts.MemberTypeAdmin,
|
|
|
|
|
+ Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
|
|
|
|
|
+ })
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ mId, _ := mRes.LastInsertId()
|
|
|
|
|
+
|
|
|
|
|
+ t.Cleanup(func() {
|
|
|
|
|
+ testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
|
|
|
|
|
+ testutil.CleanTable(ctx, conn, "`sys_user`", userId)
|
|
|
|
|
+ testutil.CleanTable(ctx, conn, "`sys_product`", pId)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ m, _ := newTestMiddleware()
|
|
|
|
|
+ tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
|
|
|
|
|
+ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username,
|
|
|
|
|
+ ProductCode: pCode, TokenVersion: 1,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") })
|
|
|
|
|
+ req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
|
|
|
+ req.Header.Set("Authorization", "Bearer "+tokenStr)
|
|
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
|
|
+ handler.ServeHTTP(rr, req)
|
|
|
|
|
+
|
|
|
|
|
+ var body response.Body
|
|
|
|
|
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
|
|
|
|
+ assert.Equal(t, 403, body.Code,
|
|
|
|
|
+ "L-B 矩阵: 账号冻结(403) 胜出, 而非 TokenVersion(401) 或 ProductStatus(403/禁用)")
|
|
|
|
|
+ assert.Equal(t, "账号已被冻结", body.Msg,
|
|
|
|
|
+ "L-B 矩阵: 冻结文案必须先于 '登录已失效'/'产品禁用' 返回给客户端")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TC-0756: TokenVersion OK + 产品启用 + 非超管 + MemberType 为空 -> 403 "您已不是该产品的有效成员"。
|
|
|
|
|
+// 场景: 用户曾是产品成员, 后被移除, 但老 token 未过期; 本用例保证"移除成员"的写路径会被读路径识别。
|
|
|
|
|
+func TestJwtAuthMiddleware_NonMemberRejected(t *testing.T) {
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ conn := testutil.GetTestSqlConn()
|
|
|
|
|
+ models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
|
|
|
|
|
+ now := time.Now().Unix()
|
|
|
|
|
+
|
|
|
|
|
+ pCode := "mw_nm_" + testutil.UniqueId()
|
|
|
|
|
+ pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{
|
|
|
|
|
+ Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s",
|
|
|
|
|
+ Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
|
|
|
|
|
+ })
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ pId, _ := pRes.LastInsertId()
|
|
|
|
|
+
|
|
|
|
|
+ username := "mw_nm_u_" + testutil.UniqueId()
|
|
|
|
|
+ uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
|
|
|
|
|
+ Username: username, Password: "x", Nickname: "n",
|
|
|
|
|
+ Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
|
|
|
|
|
+ MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 0,
|
|
|
|
|
+ CreateTime: now, UpdateTime: now,
|
|
|
|
|
+ })
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ userId, _ := uRes.LastInsertId()
|
|
|
|
|
+
|
|
|
|
|
+ t.Cleanup(func() {
|
|
|
|
|
+ testutil.CleanTable(ctx, conn, "`sys_user`", userId)
|
|
|
|
|
+ testutil.CleanTable(ctx, conn, "`sys_product`", pId)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ m, _ := newTestMiddleware()
|
|
|
|
|
+ tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
|
|
|
|
|
+ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username,
|
|
|
|
|
+ ProductCode: pCode, TokenVersion: 0,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") })
|
|
|
|
|
+ req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
|
|
|
+ req.Header.Set("Authorization", "Bearer "+tokenStr)
|
|
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
|
|
+ handler.ServeHTTP(rr, req)
|
|
|
|
|
+
|
|
|
|
|
+ var body response.Body
|
|
|
|
|
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
|
|
|
|
+ assert.Equal(t, 403, body.Code)
|
|
|
|
|
+ assert.Equal(t, "您已不是该产品的有效成员", body.Msg,
|
|
|
|
|
+ "L-B 矩阵: MemberType 空 + 非超管 + 产品启用 必须精确命中'不是有效成员'文案")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TC-0757: 超级管理员 + ProductCode 携带 + MemberType 空 -> 正常放行。
|
|
|
|
|
+// 超管"旁路"分支不能被移除, 否则超管在产品上下文会被错误踢出。
|
|
|
|
|
+func TestJwtAuthMiddleware_SuperAdminBypassesMemberCheck(t *testing.T) {
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ conn := testutil.GetTestSqlConn()
|
|
|
|
|
+ models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
|
|
|
|
|
+ now := time.Now().Unix()
|
|
|
|
|
+
|
|
|
|
|
+ pCode := "mw_sa_" + testutil.UniqueId()
|
|
|
|
|
+ pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{
|
|
|
|
|
+ Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s",
|
|
|
|
|
+ Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
|
|
|
|
|
+ })
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ pId, _ := pRes.LastInsertId()
|
|
|
|
|
+
|
|
|
|
|
+ username := "mw_sa_u_" + testutil.UniqueId()
|
|
|
|
|
+ uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
|
|
|
|
|
+ Username: username, Password: "x", Nickname: "n",
|
|
|
|
|
+ Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminYes,
|
|
|
|
|
+ MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 0,
|
|
|
|
|
+ CreateTime: now, UpdateTime: now,
|
|
|
|
|
+ })
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ userId, _ := uRes.LastInsertId()
|
|
|
|
|
+
|
|
|
|
|
+ t.Cleanup(func() {
|
|
|
|
|
+ testutil.CleanTable(ctx, conn, "`sys_user`", userId)
|
|
|
|
|
+ testutil.CleanTable(ctx, conn, "`sys_product`", pId)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ m, _ := newTestMiddleware()
|
|
|
|
|
+ tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
|
|
|
|
|
+ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username,
|
|
|
|
|
+ ProductCode: pCode, TokenVersion: 0,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ var reached bool
|
|
|
|
|
+ handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ reached = true
|
|
|
|
|
+ w.WriteHeader(http.StatusOK)
|
|
|
|
|
+ })
|
|
|
|
|
+ req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
|
|
|
+ req.Header.Set("Authorization", "Bearer "+tokenStr)
|
|
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
|
|
+ handler.ServeHTTP(rr, req)
|
|
|
|
|
+
|
|
|
|
|
+ assert.Equal(t, http.StatusOK, rr.Code, "L-B 矩阵: 超管必须放行, 即使在产品上下文无 MemberType")
|
|
|
|
|
+ assert.True(t, reached, "请求必须到达业务 handler")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TC-0758: 无 ProductCode 时, Frozen 用户 + TokenVersion 失配 -> 403 "账号已被冻结"。
|
|
|
|
|
+// 验证即使不走产品相关分支, Status 检查仍先于 TokenVersion 裁决。
|
|
|
|
|
+func TestJwtAuthMiddleware_FrozenBeatsTokenVersionNoProduct(t *testing.T) {
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ conn := testutil.GetTestSqlConn()
|
|
|
|
|
+ models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
|
|
|
|
|
+ now := time.Now().Unix()
|
|
|
|
|
+
|
|
|
|
|
+ username := "mw_fz2_u_" + testutil.UniqueId()
|
|
|
|
|
+ uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
|
|
|
|
|
+ Username: username, Password: "x", Nickname: "n",
|
|
|
|
|
+ Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
|
|
|
|
|
+ MustChangePassword: 2, Status: consts.StatusDisabled, TokenVersion: 7,
|
|
|
|
|
+ CreateTime: now, UpdateTime: now,
|
|
|
|
|
+ })
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ userId, _ := uRes.LastInsertId()
|
|
|
|
|
+ t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
|
|
|
|
|
+
|
|
|
|
|
+ m, _ := newTestMiddleware()
|
|
|
|
|
+ tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
|
|
|
|
|
+ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username,
|
|
|
|
|
+ TokenVersion: 0,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") })
|
|
|
|
|
+ req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
|
|
|
+ req.Header.Set("Authorization", "Bearer "+tokenStr)
|
|
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
|
|
+ handler.ServeHTTP(rr, req)
|
|
|
|
|
+
|
|
|
|
|
+ var body response.Body
|
|
|
|
|
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
|
|
|
|
+ assert.Equal(t, 403, body.Code)
|
|
|
|
|
+ assert.Equal(t, "账号已被冻结", body.Msg)
|
|
|
|
|
+}
|