| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678 |
- package pub
- import (
- "context"
- "database/sql"
- "errors"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "github.com/zeromicro/go-zero/core/limit"
- "github.com/zeromicro/go-zero/core/stores/redis"
- authHelper "perms-system-server/internal/logic/auth"
- "perms-system-server/internal/middleware"
- permModel "perms-system-server/internal/model/perm"
- productmemberModel "perms-system-server/internal/model/productmember"
- userModel "perms-system-server/internal/model/user"
- "perms-system-server/internal/response"
- "perms-system-server/internal/testutil"
- "perms-system-server/internal/types"
- "sync"
- "sync/atomic"
- "testing"
- "time"
- )
- func insertRefreshTestUser(t *testing.T, ctx context.Context, username, password string, status, isSuperAdmin int64) (int64, func()) {
- t.Helper()
- svcCtx := newTestSvcCtx()
- conn := testutil.GetTestSqlConn()
- now := time.Now().Unix()
- hashed := testutil.HashPassword(password)
- res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
- Username: username,
- Password: hashed,
- Nickname: username,
- Avatar: sql.NullString{},
- Email: username + "@test.com",
- Phone: "13800000000",
- Remark: "",
- DeptId: 0,
- IsSuperAdmin: isSuperAdmin,
- MustChangePassword: 2,
- Status: status,
- CreateTime: now,
- UpdateTime: now,
- })
- require.NoError(t, err)
- id, _ := res.LastInsertId()
- cleanup := func() {
- testutil.CleanTable(ctx, conn, "`sys_user`", id)
- }
- return id, cleanup
- }
- // TC-0026: 正常刷新(refreshToken从header获取,原样返回不重新生成)
- func TestRefreshToken_Normal(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- username := testutil.UniqueId()
- password := "TestPass123"
- userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2)
- t.Cleanup(cleanUser)
- refreshToken, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret,
- svcCtx.Config.Auth.RefreshExpire,
- userId, "", 0,
- )
- require.NoError(t, err)
- logic := NewRefreshTokenLogic(ctx, svcCtx)
- resp, err := logic.RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + refreshToken,
- })
- require.NoError(t, err)
- require.NotNil(t, resp)
- assert.NotEmpty(t, resp.AccessToken)
- assert.NotEmpty(t, resp.RefreshToken, "应返回新的refreshToken")
- assert.NotEqual(t, resp.AccessToken, resp.RefreshToken, "accessToken和refreshToken应不同")
- assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳")
- assert.Equal(t, userId, resp.UserInfo.UserId)
- assert.Equal(t, username, resp.UserInfo.Username)
- }
- // TC-0027: 不带productCode(回退)
- func TestRefreshToken_FallbackToClaimsProductCode(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- conn := testutil.GetTestSqlConn()
- username := testutil.UniqueId()
- password := "TestPass123"
- pc := testutil.UniqueId()
- now := time.Now().Unix()
- userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2)
- t.Cleanup(cleanUser)
- _, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
- t.Cleanup(cleanProduct)
- pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
- ProductCode: pc, UserId: userId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- pmId, _ := pmRes.LastInsertId()
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId) })
- permCode := testutil.UniqueId()
- permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
- ProductCode: pc, Name: "refresh_perm", Code: permCode, Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- permId, _ := permRes.LastInsertId()
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permId) })
- refreshToken, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret,
- svcCtx.Config.Auth.RefreshExpire,
- userId, pc, 0,
- )
- require.NoError(t, err)
- logic := NewRefreshTokenLogic(ctx, svcCtx)
- resp, err := logic.RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + refreshToken,
- })
- require.NoError(t, err)
- require.NotNil(t, resp)
- assert.Equal(t, "ADMIN", resp.UserInfo.MemberType)
- assert.Contains(t, resp.UserInfo.Perms, permCode)
- }
- // TC-0028: token无效
- func TestRefreshToken_InvalidToken(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- logic := NewRefreshTokenLogic(ctx, svcCtx)
- resp, err := logic.RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer invalid.token.string",
- })
- require.Nil(t, resp)
- require.Error(t, err)
- var codeErr *response.CodeError
- require.True(t, errors.As(err, &codeErr))
- assert.Equal(t, 401, codeErr.Code())
- assert.Equal(t, "refreshToken无效或已过期", codeErr.Error())
- }
- // TC-0029: 用户已被删除 —— 修复后必须区分"不存在"(401) 与"冻结"(403)。
- //
- // 修复前:Loader 对不存在用户返回空壳 UserDetails(Status=0),RefreshToken 走到"账号已被冻结"分支 (403),
- //
- // 将"用户不存在"与"账号冻结"两个语义混淆,监控告警与运维处置策略无法区分。
- //
- // 修复后:Loader 返回 (ud, nil) 且 ud.Username == "",RefreshToken 显式回 401 "用户不存在或已被删除"。
- //
- // 这样客户端/前端才能走"注销本地会话 + 返回登录页"的终态流程,而不是提示"账号已冻结请联系管理员"。
- func TestRefreshToken_UserDeleted(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- nonExistentUserId := int64(999999999)
- refreshToken, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret,
- svcCtx.Config.Auth.RefreshExpire,
- nonExistentUserId, "", 0,
- )
- require.NoError(t, err)
- logic := NewRefreshTokenLogic(ctx, svcCtx)
- resp, err := logic.RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + refreshToken,
- })
- require.Nil(t, resp)
- require.Error(t, err)
- var codeErr *response.CodeError
- require.True(t, errors.As(err, &codeErr))
- assert.Equal(t, 401, codeErr.Code(), "用户不存在必须走 401,不得与冻结态 (403) 混淆")
- assert.Equal(t, "用户不存在或已被删除", codeErr.Error())
- }
- // TC-0030: 账号冻结
- func TestRefreshToken_AccountFrozen(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- username := testutil.UniqueId()
- password := "TestPass123"
- userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 2, 2)
- t.Cleanup(cleanUser)
- refreshToken, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret,
- svcCtx.Config.Auth.RefreshExpire,
- userId, "", 0,
- )
- require.NoError(t, err)
- logic := NewRefreshTokenLogic(ctx, svcCtx)
- resp, err := logic.RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + refreshToken,
- })
- require.Nil(t, resp)
- require.Error(t, err)
- var codeErr *response.CodeError
- require.True(t, errors.As(err, &codeErr))
- assert.Equal(t, 403, codeErr.Code())
- assert.Equal(t, "账号已被冻结", codeErr.Error())
- }
- // TC-0032: 尝试切换产品被拒绝
- func TestRefreshToken_ProductCodeSwitchRejected(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- username := testutil.UniqueId()
- password := "TestPass123"
- userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2)
- t.Cleanup(cleanUser)
- refreshToken, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret,
- svcCtx.Config.Auth.RefreshExpire,
- userId, "product_a", 0,
- )
- require.NoError(t, err)
- logic := NewRefreshTokenLogic(ctx, svcCtx)
- resp, err := logic.RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + refreshToken,
- ProductCode: "product_b",
- })
- require.Nil(t, resp)
- require.Error(t, err)
- var codeErr *response.CodeError
- require.True(t, errors.As(err, &codeErr))
- assert.Equal(t, 400, codeErr.Code())
- assert.Equal(t, "刷新令牌不允许切换产品", codeErr.Error())
- }
- // TC-0033: TokenVersion不匹配时拒绝刷新
- func TestRefreshToken_TokenVersionMismatch(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- username := testutil.UniqueId()
- password := "TestPass123"
- userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2)
- t.Cleanup(cleanUser)
- refreshToken, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret,
- svcCtx.Config.Auth.RefreshExpire,
- userId, "", 999,
- )
- require.NoError(t, err)
- logic := NewRefreshTokenLogic(ctx, svcCtx)
- resp, err := logic.RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + refreshToken,
- })
- require.Nil(t, resp)
- require.Error(t, err)
- var codeErr *response.CodeError
- require.True(t, errors.As(err, &codeErr))
- assert.Equal(t, 401, codeErr.Code())
- assert.Equal(t, "登录状态已失效,请重新登录", codeErr.Error())
- }
- // TC-0034: 使用accessToken作为refreshToken被拒绝
- func TestRefreshToken_AccessTokenRejected(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- username := testutil.UniqueId()
- password := "TestPass123"
- userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2)
- t.Cleanup(cleanUser)
- accessToken, err := authHelper.GenerateAccessToken(
- svcCtx.Config.Auth.RefreshSecret,
- svcCtx.Config.Auth.AccessExpire,
- userId, username, "", "", 0,
- )
- require.NoError(t, err)
- logic := NewRefreshTokenLogic(ctx, svcCtx)
- resp, err := logic.RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + accessToken,
- })
- require.Nil(t, resp)
- require.Error(t, err)
- var codeErr *response.CodeError
- require.True(t, errors.As(err, &codeErr))
- assert.Equal(t, 401, codeErr.Code())
- assert.Equal(t, "refreshToken无效或已过期", codeErr.Error())
- }
- // TC-0035: 产品成员已移除时拒绝刷新
- func TestRefreshToken_MemberRemovedRejected(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- conn := testutil.GetTestSqlConn()
- username := testutil.UniqueId()
- password := "TestPass123"
- pc := testutil.UniqueId()
- now := time.Now().Unix()
- userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2)
- t.Cleanup(cleanUser)
- _, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
- t.Cleanup(cleanProduct)
- pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
- ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- pmId, _ := pmRes.LastInsertId()
- refreshToken, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret,
- svcCtx.Config.Auth.RefreshExpire,
- userId, pc, 0,
- )
- require.NoError(t, err)
- testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId)
- logic := NewRefreshTokenLogic(ctx, svcCtx)
- resp, err := logic.RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + refreshToken,
- })
- require.Nil(t, resp)
- require.Error(t, err)
- var codeErr *response.CodeError
- require.True(t, errors.As(err, &codeErr))
- assert.Equal(t, 403, codeErr.Code())
- assert.Equal(t, "您已不是该产品的成员", codeErr.Error())
- }
- // TC-0031: 超管+productCode(refreshToken原样返回)
- func TestRefreshToken_SuperAdminWithProductCode(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- conn := testutil.GetTestSqlConn()
- username := testutil.UniqueId()
- password := "TestPass123"
- pc := testutil.UniqueId()
- now := time.Now().Unix()
- userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 1)
- t.Cleanup(cleanUser)
- _, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
- t.Cleanup(cleanProduct)
- permCode := testutil.UniqueId()
- permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
- ProductCode: pc, Name: "sa_refresh_perm", Code: permCode, Status: 1, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- permId, _ := permRes.LastInsertId()
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permId) })
- refreshToken, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret,
- svcCtx.Config.Auth.RefreshExpire,
- userId, pc, 0,
- )
- require.NoError(t, err)
- logic := NewRefreshTokenLogic(ctx, svcCtx)
- resp, err := logic.RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + refreshToken,
- ProductCode: pc,
- })
- require.NoError(t, err)
- require.NotNil(t, resp)
- assert.NotEmpty(t, resp.RefreshToken, "应返回新的refreshToken")
- assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType)
- assert.Contains(t, resp.UserInfo.Perms, permCode)
- assert.Equal(t, int64(1), resp.UserInfo.IsSuperAdmin)
- }
- func TestRefreshToken_ConcurrentSameToken_SingleWinner(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- username := "rt_cas_" + testutil.UniqueId()
- userId, cleanUser := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2)
- t.Cleanup(cleanUser)
- // 禁用 TokenOpLimiter,以让本测试的变量只剩"并发 CAS 胜负"。
- svcCtx.TokenOpLimiter = nil
- rt, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
- userId, "", 0,
- )
- require.NoError(t, err)
- // 限制在 6 并发以避免触发 go-zero sqlx breaker(单机 MySQL + breaker 对同批次突发
- // 的并发 UPDATE 容易误伤,生产里 refreshToken 也是 per-user 限频 + CAS 双层保护,
- // 没机会打成这么高的并发)。CAS "唯一胜出" 的契约在 N=6 时已足以钉死。
- const N = 6
- var (
- wg sync.WaitGroup
- okCount int32
- authFailCnt int32
- otherErr atomic.Value
- )
- start := make(chan struct{})
- for i := 0; i < N; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- <-start
- resp, e := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + rt,
- })
- switch {
- case e == nil && resp != nil:
- atomic.AddInt32(&okCount, 1)
- case e != nil:
- var ce *response.CodeError
- if errors.As(e, &ce) && ce.Code() == 401 &&
- ce.Error() == "登录状态已失效,请重新登录" {
- atomic.AddInt32(&authFailCnt, 1)
- } else {
- otherErr.Store(e)
- }
- }
- }()
- }
- close(start)
- wg.Wait()
- if v := otherErr.Load(); v != nil {
- t.Fatalf("并发 RefreshToken 出现非预期错误:%v", v)
- }
- assert.Equal(t, int32(1), atomic.LoadInt32(&okCount),
- "会话劫持防线:重放同一旧 refreshToken 的 N 个并发请求必须只有 1 个成功")
- assert.Equal(t, int32(N-1), atomic.LoadInt32(&authFailCnt),
- "其他并发者必须返回 401 '登录状态已失效'")
- // DB 必然只递增 1。
- u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, int64(1), u.TokenVersion,
- "DB tokenVersion 递增幅度就是 CAS 成功次数 → 只能是 1")
- }
- func TestRefreshToken_TokenOpLimiter_BlocksBurst(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- username := "rt_rl_" + testutil.UniqueId()
- password := "TestPass123"
- userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2)
- t.Cleanup(cleanUser)
- cfg := testutil.GetTestConfig()
- rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
- svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:refresh:ut:"+testutil.UniqueId())
- mkReq := func(tv int64) *types.RefreshTokenReq {
- rt, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
- userId, "", tv)
- require.NoError(t, err)
- return &types.RefreshTokenReq{Authorization: "Bearer " + rt}
- }
- resp1, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(0))
- require.NoError(t, err, "首次刷新应放行")
- require.NotNil(t, resp1)
- // DB tokenVersion 已变为 1,旧 claims.TokenVersion=0 的 refreshToken 已失效,
- // 所以第二次必须用新 token;但限流判定在 TokenVersion 校验之**后**、IncrementTokenVersion 之**前**,
- // 因此使用新版本号构造的 token 会先通过前置校验,再被 TokenOpLimiter 拦截。
- u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- tvAfterFirst := u.TokenVersion
- require.Equal(t, int64(1), tvAfterFirst)
- _, err = NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(tvAfterFirst))
- require.Error(t, err, "超限的第二次刷新必须被 429 拦截")
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce))
- assert.Equal(t, 429, ce.Code())
- assert.Contains(t, ce.Error(), "过于频繁")
- uAfter, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, tvAfterFirst, uAfter.TokenVersion,
- "被限流的 refresh 请求绝不可递增 tokenVersion")
- }
- // TC-0742: -B 修复 —— 限流按用户粒度隔离(productCode 无关)。
- // 场景:同一用户连续两次带 productCode=空的刷新请求,若限流命中,不会影响其它用户。
- func TestRefreshToken_TokenOpLimiter_PerUserIsolated(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- uaId, cleanA := insertRefreshTestUser(t, ctx, "rt_iso_a_"+testutil.UniqueId(), "TestPass123", 1, 2)
- t.Cleanup(cleanA)
- ubId, cleanB := insertRefreshTestUser(t, ctx, "rt_iso_b_"+testutil.UniqueId(), "TestPass123", 1, 2)
- t.Cleanup(cleanB)
- cfg := testutil.GetTestConfig()
- rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
- svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:refresh:iso:"+testutil.UniqueId())
- mkReq := func(uid, tv int64) *types.RefreshTokenReq {
- rt, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
- uid, "", tv)
- require.NoError(t, err)
- return &types.RefreshTokenReq{Authorization: "Bearer " + rt}
- }
- // A:两次刷新,第 2 次必 429
- _, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(uaId, 0))
- require.NoError(t, err)
- _, err = NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(uaId, 1))
- require.Error(t, err)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce))
- require.Equal(t, 429, ce.Code())
- // B 应当还能刷新
- respB, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(ubId, 0))
- require.NoError(t, err, "B 用户的限流桶应当独立于 A")
- require.NotNil(t, respB)
- }
- func TestRefreshToken_M3_SuccessEmbedsFreshVersion(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- svcCtx.TokenOpLimiter = nil
- username := "rt_m3_ok_" + testutil.UniqueId()
- userId, cleanup := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2)
- t.Cleanup(cleanup)
- rt, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
- userId, "", 0,
- )
- require.NoError(t, err)
- resp, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + rt,
- })
- require.NoError(t, err)
- require.NotNil(t, resp)
- u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, int64(1), u.TokenVersion, "正常刷新 DB tokenVersion 必须 +1")
- var accessClaims middleware.Claims
- _, err = authHelper.ParseWithHMAC(resp.AccessToken, svcCtx.Config.Auth.AccessSecret, &accessClaims)
- require.NoError(t, err, "新 accessToken 必须可解析")
- assert.Equal(t, u.TokenVersion, accessClaims.TokenVersion,
- "新 accessToken.TokenVersion 必须等于 DB 新 tokenVersion;不等说明 CAS/签名顺序错位")
- refreshClaims, err := authHelper.ParseRefreshToken(resp.RefreshToken, svcCtx.Config.Auth.RefreshSecret)
- require.NoError(t, err, "新 refreshToken 必须可解析")
- assert.Equal(t, u.TokenVersion, refreshClaims.TokenVersion,
- "新 refreshToken.TokenVersion 必须等于 DB 新 tokenVersion;客户端下一次刷新必须可用")
- }
- // TC-0984: CAS 失败路径 —— 模拟并发抢先递增后再刷新,DB 不得再次被 +1。
- func TestRefreshToken_M3_CASMismatch_DoesNotDoubleAdvance(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- svcCtx.TokenOpLimiter = nil
- username := "rt_m3_cas_" + testutil.UniqueId()
- userId, cleanup := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2)
- t.Cleanup(cleanup)
- // 构造旧 refresh token(claims.TokenVersion=0)。
- rt, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
- userId, "", 0,
- )
- require.NoError(t, err)
- // 模拟"并发赢家已经把 DB tokenVersion 推到 1":直接 CAS 一次。
- newVer, err := svcCtx.SysUserModel.IncrementTokenVersionIfMatch(ctx, userId, username, 0)
- require.NoError(t, err)
- require.Equal(t, int64(1), newVer)
- // 清掉用户缓存,确保下一步 Load 能读到 DB 的最新 tokenVersion=1。
- svcCtx.UserDetailsLoader.Clean(ctx, userId)
- // 现在第二个刷新进来:ud.TokenVersion=1 ≠ claims.TokenVersion=0,
- // 会在 logic 第 73 行 "claims.TokenVersion != ud.TokenVersion" 被直接 401 拒,
- // 根本到不了 Generate/CAS。DB 不得再次 +1。
- resp, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + rt,
- })
- assert.Nil(t, resp)
- require.Error(t, err)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce), "必须是 response.CodeError")
- assert.Equal(t, 401, ce.Code(), "claims 过期必须是 401")
- assert.Equal(t, "登录状态已失效,请重新登录", ce.Error())
- // 关键断言:失败分支不得二次推进 tokenVersion。
- u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, int64(1), u.TokenVersion,
- "失败分支必须不推进 tokenVersion;若变成 2 说明 CAS 被放在"+
- "签名/校验前,已经把用户状态破坏了")
- }
- // TC-0985: 重放拦截 —— 用第一次刷新拿到的新 refreshToken 再刷一次必须成功;
- // 再拿"同一个新 refreshToken"做第三次刷新必须被 401 拦截(tokenVersion 已 +2,claims=+1)。
- // 这组断言同时证明 修复之后"预签 token 的版本号 == 最终 DB 版本号"的强契约。
- func TestRefreshToken_M3_NewRefreshTokenMatchesDBVersion(t *testing.T) {
- ctx := context.Background()
- svcCtx := newTestSvcCtx()
- svcCtx.TokenOpLimiter = nil
- username := "rt_m3_chain_" + testutil.UniqueId()
- userId, cleanup := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2)
- t.Cleanup(cleanup)
- first, err := authHelper.GenerateRefreshToken(
- svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
- userId, "", 0,
- )
- require.NoError(t, err)
- r1, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + first,
- })
- require.NoError(t, err)
- require.NotNil(t, r1)
- // 等 loader 缓存被 Clean 后,再用 r1.RefreshToken 续签,理应成功,tokenVersion 从 1 → 2。
- r2, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + r1.RefreshToken,
- })
- require.NoError(t, err, "新 refreshToken 必须能顶替旧的继续刷新")
- require.NotNil(t, r2)
- u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, int64(2), u.TokenVersion)
- // 第三次重放第一步就签下的 r1 → 401,DB 不得再 +1。
- _, err = NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
- Authorization: "Bearer " + r1.RefreshToken,
- })
- require.Error(t, err)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce))
- assert.Equal(t, 401, ce.Code(),
- "重放旧 refreshToken 必须 401;服务端绝不得因签 token 副作用推进 DB")
- u2, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, int64(2), u2.TokenVersion, "重放失败分支不得推进 tokenVersion")
- }
|