| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165 |
- package pub
- import (
- "context"
- "errors"
- "testing"
- authHelper "perms-system-server/internal/logic/auth"
- "perms-system-server/internal/middleware"
- "perms-system-server/internal/response"
- "perms-system-server/internal/testutil"
- "perms-system-server/internal/types"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
- // ---------------------------------------------------------------------------
- // 覆盖目标:审计 M-3(第 8 轮)—— RefreshToken 必须"先试签 → 再 CAS → 最后清缓存",
- // 使得签名失败/CAS 失败两条分支都不会推进 tokenVersion,不会出现"tokenVersion 已 +1
- // 但客户端没拿到新 refreshToken"的放大式强制登出。
- //
- // 由于 HMAC 实际不会失败(除非 OOM),本组测试从可观测契约入手:
- // TC-0983: 成功路径下新 token 的 claims.TokenVersion 必须严格等于 DB 的新 tokenVersion;
- // 如果顺序被颠倒(CAS 后再签),仍然成立,所以必须配合 TC-0984 防止退化。
- // TC-0984: 模拟 CAS ErrTokenVersionMismatch(人工抢先把 DB TokenVersion 再递增一次),
- // 触发 401 "登录状态已失效";DB 再次递增的幅度必须 = 0,证明失败分支没有再 +1。
- // TC-0985: 新签发的 refresh token 能被 ParseRefreshToken 解出且 claims.TokenVersion
- // = DB 新 tokenVersion —— 保证客户端拿到的 refreshToken 在下一轮必然可用;
- // 这就是 M-3 要守护的"客户端不会被自己的服务端新签 token 背刺"契约。
- // ---------------------------------------------------------------------------
- // TC-0983: 成功路径 —— 新 access/refresh 的 tokenVersion 必须等于 DB 新 tokenVersion。
- 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, "M-3:正常刷新 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,
- "M-3:新 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,
- "M-3:新 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,
- "M-3:失败分支必须不推进 tokenVersion;若变成 2 说明 CAS 被放在"+
- "签名/校验前,已经把用户状态破坏了")
- }
- // TC-0985: 重放拦截 —— 用第一次刷新拿到的新 refreshToken 再刷一次必须成功;
- // 再拿"同一个新 refreshToken"做第三次刷新必须被 401 拦截(tokenVersion 已 +2,claims=+1)。
- // 这组断言同时证明 M-3 修复之后"预签 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(),
- "M-3:重放旧 refreshToken 必须 401;服务端绝不得因签 token 副作用推进 DB")
- u2, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, int64(2), u2.TokenVersion, "M-3:重放失败分支不得推进 tokenVersion")
- }
|