| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830 |
- 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: "[email protected]",
- 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: "[email protected]",
- 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 类型解耦,保证所有调用方可以复用")
- }
|