package middleware_test import ( "context" "crypto/hmac" "crypto/sha256" "database/sql" "encoding/base64" "encoding/json" "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/redis" "github.com/zeromicro/go-zero/rest/httpx" "net/http" "net/http/httptest" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" "perms-system-server/internal/middleware" "perms-system-server/internal/model" productModel "perms-system-server/internal/model/product" productmemberModel "perms-system-server/internal/model/productmember" "perms-system-server/internal/model/user" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/testutil" "testing" "time" ) const testAccessSecret = "test-middleware-secret" func generateTestToken(secret string, expireSeconds int64, claims *middleware.Claims) string { now := time.Now() claims.RegisteredClaims = jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expireSeconds) * time.Second)), IssuedAt: jwt.NewNumericDate(now), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenStr, _ := token.SignedString([]byte(secret)) return tokenStr } func newTestMiddleware() (*middleware.JwtAuthMiddleware, *loaders.UserDetailsLoader) { cfg := testutil.GetTestConfig() conn := testutil.GetTestSqlConn() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) loader := loaders.NewUserDetailsLoader(rds, testutil.GetTestCachePrefix(), models) m := middleware.NewJwtAuthMiddleware(testAccessSecret, loader) return m, loader } func createTestUser(t *testing.T, username string) (int64, func()) { t.Helper() ctx := context.Background() conn := testutil.GetTestSqlConn() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) now := time.Now().Unix() u := &user.SysUser{ Username: username, Password: testutil.HashPassword("test123"), Nickname: "test_nick", Avatar: sql.NullString{Valid: false}, Email: "test@example.com", Phone: "13800000000", Remark: "", DeptId: 0, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo, Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, } result, err := models.SysUserModel.Insert(ctx, u) require.NoError(t, err) userId, err := result.LastInsertId() require.NoError(t, err) cleanup := func() { testutil.CleanTable(ctx, conn, "sys_user", userId) } return userId, cleanup } func init() { response.Setup() } // TC-0258: `Authorization: Bearer {valid}` func TestJwtAuthMiddleware_Handle(t *testing.T) { m, _ := newTestMiddleware() t.Run("valid token", func(t *testing.T) { username := "mw_valid_" + testutil.UniqueId() userId, cleanup := createTestUser(t, username) defer cleanup() tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username, ProductCode: "", }) var capturedCtx context.Context handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { capturedCtx = r.Context() 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) assert.Equal(t, userId, middleware.GetUserId(capturedCtx)) assert.Equal(t, "", middleware.GetProductCode(capturedCtx)) details := middleware.GetUserDetails(capturedCtx) require.NotNil(t, details) assert.Equal(t, username, details.Username) assert.Equal(t, "", details.MemberType) assert.False(t, details.IsSuperAdmin) }) t.Run("no authorization header", func(t *testing.T) { handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body err := json.Unmarshal(rr.Body.Bytes(), &body) require.NoError(t, err) assert.Equal(t, 401, body.Code) assert.Equal(t, "未登录", body.Msg) }) t.Run("no Bearer prefix", func(t *testing.T) { handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Basic some-token") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body err := json.Unmarshal(rr.Body.Bytes(), &body) require.NoError(t, err) assert.Equal(t, 401, body.Code) assert.Equal(t, "token格式错误", body.Msg) }) t.Run("invalid token", func(t *testing.T) { handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer invalid-token-string") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body err := json.Unmarshal(rr.Body.Bytes(), &body) require.NoError(t, err) assert.Equal(t, 401, body.Code) assert.Equal(t, "token无效或已过期", body.Msg) }) t.Run("wrong secret", func(t *testing.T) { tokenStr := generateTestToken("wrong-secret", 3600, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: 1, }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body err := json.Unmarshal(rr.Body.Bytes(), &body) require.NoError(t, err) assert.Equal(t, 401, body.Code) assert.Equal(t, "token无效或已过期", body.Msg) }) t.Run("expired token", func(t *testing.T) { tokenStr := generateTestToken(testAccessSecret, -10, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: 1, }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body err := json.Unmarshal(rr.Body.Bytes(), &body) require.NoError(t, err) assert.Equal(t, 401, body.Code) assert.Equal(t, "token无效或已过期", body.Msg) }) // TC-0264: refresh token 不应被中间件接受 t.Run("refresh token rejected", func(t *testing.T) { tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{ TokenType: consts.TokenTypeRefresh, UserId: 100, }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body err := json.Unmarshal(rr.Body.Bytes(), &body) require.NoError(t, err) assert.Equal(t, 401, body.Code) assert.Equal(t, "token无效或类型错误", body.Msg) }) t.Run("frozen user rejected", func(t *testing.T) { username := "mw_frozen_" + testutil.UniqueId() userId, cleanup := createTestUser(t, username) defer cleanup() ctx := context.Background() conn := testutil.GetTestSqlConn() now := time.Now().Unix() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) err := models.SysUserModel.Update(ctx, &user.SysUser{ Id: userId, Username: username, Password: testutil.HashPassword("test123"), Nickname: "test_nick", Avatar: sql.NullString{Valid: false}, Email: "test@example.com", Phone: "13800000000", DeptId: 0, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo, Status: consts.StatusDisabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username, ProductCode: "", }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body err = json.Unmarshal(rr.Body.Bytes(), &body) require.NoError(t, err) assert.Equal(t, 403, body.Code) assert.Equal(t, "账号已被冻结", body.Msg) }) } // TC-0306: ctx含userId=100 func TestGetUserId(t *testing.T) { ctx := context.Background() assert.Equal(t, int64(0), middleware.GetUserId(ctx)) ctx = middleware.WithUserDetails(ctx, &loaders.UserDetails{UserId: 42}) assert.Equal(t, int64(42), middleware.GetUserId(ctx)) ctx2 := context.Background() assert.Equal(t, int64(0), middleware.GetUserId(ctx2)) } // TC-0275: 空ctx func TestGetProductCode(t *testing.T) { ctx := context.Background() assert.Equal(t, "", middleware.GetProductCode(ctx)) ctx = middleware.WithUserDetails(ctx, &loaders.UserDetails{ProductCode: "p1"}) assert.Equal(t, "p1", middleware.GetProductCode(ctx)) } // TC-0309: GetUserDetails 返回完整用户信息 func TestGetUserDetails(t *testing.T) { ctx := context.Background() assert.Nil(t, middleware.GetUserDetails(ctx)) expected := &loaders.UserDetails{ UserId: 42, Username: "admin", ProductCode: "p1", MemberType: "ADMIN", IsSuperAdmin: true, } ctx = middleware.WithUserDetails(ctx, expected) got := middleware.GetUserDetails(ctx) require.NotNil(t, got) assert.Equal(t, expected.UserId, got.UserId) assert.Equal(t, expected.Username, got.Username) assert.Equal(t, expected.ProductCode, got.ProductCode) assert.Equal(t, expected.MemberType, got.MemberType) assert.Equal(t, expected.IsSuperAdmin, got.IsSuperAdmin) } // TC-0263: claims类型断言失败(防御性分支) // jwt.ParseWithClaims(tokenStr, &Claims{}, keyFunc) 始终将 token.Claims 设为 *Claims, // 且解析失败时 Handle 已在 err!=nil 分支提前返回,因此 !ok 分支不可达。 func TestJwtAuthMiddleware_Handle_ClaimsTypeAssertionUnreachable(t *testing.T) { t.Skip("defensive branch: unreachable via jwt.ParseWithClaims — claims is always *Claims") } // suppress unused import warning for httpx var _ = httpx.Error func TestJwtAuthMiddleware_TokenVersionCheckedBeforeProductStatus(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) now := time.Now().Unix() // 1) 创建"已禁用"的产品 pCode := "mw_ord_" + 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() // 2) 创建启用中的用户,TokenVersion=5 username := "mw_ord_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: 5, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := uRes.LastInsertId() // 3) 该用户是禁用产品的 ADMIN 成员 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() // 携带 stale TokenVersion=3 的 access token(DB 是 5)+ 禁用产品 code tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username, ProductCode: pCode, TokenVersion: 3, }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }) 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:TokenVersion 失配必须先于产品禁用被识别(返回 401 而非 403)") assert.Equal(t, "登录状态已失效,请重新登录", body.Msg, "L-B:文案必须是'登录状态已失效'而不是'该产品已被禁用',否则用户会被无关信息误导") } // TC-0750: -B 修复回归 —— TokenVersion 匹配但产品被禁用,仍应返回 403 "该产品已被禁用"。 // 保证修复未把所有场景都吞成 401。 func TestJwtAuthMiddleware_ProductDisabledAfterVersionOk(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) now := time.Now().Unix() pCode := "mw_ord2_" + 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_ord2_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() 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: 0, }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }) 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) } // --- 鉴权优先级完整矩阵(-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) } // 覆盖目标:ParseWithHMAC 上移到 middleware 层作为唯一入口。 // 本测试直接在 middleware 外部测试包内验证: // (1) 正常 HS256 token 放行 // (2) alg=RS256(公钥→HMAC 共享密钥混淆)显式拒绝 // (3) alg=none 拒绝 // (4) 错误 secret 签名拒绝 // (5) 非 Claims 结构的 claims 同样正确解析(保证函数与具体 claims 类型解耦) const ln1Secret = "ln1-centralized-secret" func b64urlLN1(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } func forgeTokenLN1(t *testing.T, alg string, claims any, signKey string) string { t.Helper() header := map[string]string{"alg": alg, "typ": "JWT"} hBytes, err := json.Marshal(header) require.NoError(t, err) pBytes, err := json.Marshal(claims) require.NoError(t, err) signingInput := b64urlLN1(hBytes) + "." + b64urlLN1(pBytes) mac := hmac.New(sha256.New, []byte(signKey)) mac.Write([]byte(signingInput)) return signingInput + "." + b64urlLN1(mac.Sum(nil)) } func validAccessClaimsLN1() middleware.Claims { now := time.Now() return middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: 42, Username: "ln1_u", ProductCode: "ln1_p", MemberType: consts.MemberTypeAdmin, TokenVersion: 0, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)), IssuedAt: jwt.NewNumericDate(now), }, } } // TC-1003: middleware 层 ParseWithHMAC 能正确解析合法 HS256 token。 func TestMiddlewareParseWithHMAC_LN1_HS256Valid(t *testing.T) { signed := jwt.NewWithClaims(jwt.SigningMethodHS256, validAccessClaimsLN1()) tok, err := signed.SignedString([]byte(ln1Secret)) require.NoError(t, err) parsed, err := middleware.ParseWithHMAC(tok, ln1Secret, &middleware.Claims{}) require.NoError(t, err) require.True(t, parsed.Valid) claims, ok := parsed.Claims.(*middleware.Claims) require.True(t, ok) assert.Equal(t, int64(42), claims.UserId) assert.Equal(t, consts.TokenTypeAccess, claims.TokenType) } // TC-1004: middleware 层 ParseWithHMAC 必须拒绝 alg=RS256 伪造(公钥→HMAC 混淆)。 func TestMiddlewareParseWithHMAC_LN1_RS256HeaderRejected(t *testing.T) { forged := forgeTokenLN1(t, "RS256", validAccessClaimsLN1(), ln1Secret) _, err := middleware.ParseWithHMAC(forged, ln1Secret, &middleware.Claims{}) require.Error(t, err, "必须拒绝 alg=RS256 伪造 token") assert.Contains(t, err.Error(), "unexpected signing method", "HMAC 断言失败必须产出可错误信息,方便 SOC 定位攻击尝试") } // TC-1005: middleware 层 ParseWithHMAC 必须拒绝 alg=none。 func TestMiddlewareParseWithHMAC_LN1_AlgNoneRejected(t *testing.T) { header := map[string]string{"alg": "none", "typ": "JWT"} hBytes, _ := json.Marshal(header) pBytes, _ := json.Marshal(validAccessClaimsLN1()) forged := b64urlLN1(hBytes) + "." + b64urlLN1(pBytes) + "." _, err := middleware.ParseWithHMAC(forged, ln1Secret, &middleware.Claims{}) require.Error(t, err, "alg=none 不可通过 HMAC 唯一入口") } // TC-1006: 错误 secret 签发的合法结构 HS256 token 必须被拒绝。 func TestMiddlewareParseWithHMAC_LN1_WrongSecretRejected(t *testing.T) { signed := jwt.NewWithClaims(jwt.SigningMethodHS256, validAccessClaimsLN1()) tok, err := signed.SignedString([]byte("attacker-guess")) require.NoError(t, err) _, err = middleware.ParseWithHMAC(tok, ln1Secret, &middleware.Claims{}) require.Error(t, err, "签名校验失败必须 fail-close") } // TC-1007: ParseWithHMAC 可以为任意 jwt.Claims 结构体工作(不绑 Claims 类型), // 保证 gRPC VerifyToken、RefreshToken、HTTP 中间件等所有调用点可以共用该入口。 func TestMiddlewareParseWithHMAC_LN1_ArbitraryClaimsType(t *testing.T) { type customClaims struct { Role string `json:"role"` jwt.RegisteredClaims } c := customClaims{ Role: "admin", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } signed := jwt.NewWithClaims(jwt.SigningMethodHS256, c) tok, err := signed.SignedString([]byte(ln1Secret)) require.NoError(t, err) parsed, err := middleware.ParseWithHMAC(tok, ln1Secret, &customClaims{}) require.NoError(t, err) parsedClaims, ok := parsed.Claims.(*customClaims) require.True(t, ok) assert.Equal(t, "admin", parsedClaims.Role, "唯一入口必须对任意 claims 类型解耦,保证所有调用方可以复用") }