| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- package auth
- import (
- "context"
- "database/sql"
- "fmt"
- "testing"
- "time"
- "perms-system-server/internal/loaders"
- userModel "perms-system-server/internal/model/user"
- "perms-system-server/internal/svc"
- "perms-system-server/internal/testutil"
- "github.com/golang-jwt/jwt/v4"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "github.com/zeromicro/go-zero/core/stores/redis"
- )
- // ---------------------------------------------------------------------------
- // 覆盖目标:把 HTTP / gRPC 两条 RefreshToken 路径的"试签 → CAS → Clean →
- // forensic 比对"收敛为 authHelper.RotateRefreshToken。契约上本 helper 必须:
- // 1) 成功路径:写出带 predictedVersion 的新 access + refresh、DB tokenVersion = claims+1、
- // 并触发 UD 缓存 Clean(无法直接断言 Clean 的 side effect,但通过"下一次 Load 能读到
- // 新 tokenVersion"可以间接覆盖);
- // 2) claims.TokenVersion 与 DB 不一致 → 返回 ErrTokenVersionMismatch(让 HTTP 映射 401、
- // gRPC 映射 Unauthenticated);且 DB tokenVersion **不得**被污染;
- // 3) 用户不存在(RowsAffected=0)→ 同样 ErrTokenVersionMismatch,不得被映射成"Internal"。
- // ---------------------------------------------------------------------------
- func insertRotateTestUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, username string, tokenVersion int64) int64 {
- t.Helper()
- now := time.Now().Unix()
- res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
- Username: username,
- Password: testutil.HashPassword("SomePass123"),
- Nickname: username,
- Avatar: sql.NullString{},
- Email: username + "@ut.local",
- Phone: "13800000000",
- IsSuperAdmin: 2,
- MustChangePassword: 2,
- Status: 1,
- TokenVersion: tokenVersion,
- CreateTime: now,
- UpdateTime: now,
- })
- require.NoError(t, err)
- id, err := res.LastInsertId()
- require.NoError(t, err)
- t.Cleanup(func() {
- testutil.CleanTable(ctx, testutil.GetTestSqlConn(), "`sys_user`", id)
- })
- return id
- }
- func mkRefreshClaims(userId int64, productCode string, tokenVersion int64, ttl time.Duration) *RefreshClaims {
- now := time.Now()
- return &RefreshClaims{
- TokenType: "refresh",
- UserId: userId,
- ProductCode: productCode,
- TokenVersion: tokenVersion,
- RegisteredClaims: jwt.RegisteredClaims{
- ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
- IssuedAt: jwt.NewNumericDate(now),
- },
- }
- }
- // TC-1067: helper 成功路径
- func TestRotateRefreshToken_HappyPath(t *testing.T) {
- ctx := context.Background()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- username := "r11_5_ok_" + testutil.UniqueId()
- userId := insertRotateTestUser(t, ctx, svcCtx, username, 0)
- claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
- ud := &loaders.UserDetails{
- UserId: userId,
- Username: username,
- Status: 1,
- TokenVersion: 0,
- }
- tokens, err := RotateRefreshToken(ctx, svcCtx, claims, ud)
- require.NoError(t, err, "预期 tokenVersion=0 匹配,CAS 必须成功")
- assert.NotEmpty(t, tokens.AccessToken)
- assert.NotEmpty(t, tokens.RefreshToken)
- assert.NotEqual(t, tokens.AccessToken, tokens.RefreshToken,
- "签发出的 access/refresh 必须是两条不同的 JWT,避免一次泄露即双向失陷")
- u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, int64(1), u.TokenVersion,
- "成功路径 DB.tokenVersion 必须严格 +1,不得多走也不得不走")
- // 新 refreshToken 解码后 tokenVersion 必是 1,即 predictedVersion。
- var parsed RefreshClaims
- _, err = ParseWithHMAC(tokens.RefreshToken, svcCtx.Config.Auth.RefreshSecret, &parsed)
- require.NoError(t, err)
- assert.Equal(t, int64(1), parsed.TokenVersion,
- "新 refreshToken 承诺的 tokenVersion 必须等于 predictedVersion,"+
- "即 claims.TokenVersion + 1;若错位,接入方下一次刷新会立刻 401 失效")
- }
- // TC-1068: claims.TokenVersion 与 DB 不一致 → CAS 失败 → ErrTokenVersionMismatch
- func TestRotateRefreshToken_StaleTokenVersion_Mismatch(t *testing.T) {
- ctx := context.Background()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- username := "r11_5_stale_" + testutil.UniqueId()
- // DB 里 tokenVersion 已经被之前的某次 rotate 推到 1。
- userId := insertRotateTestUser(t, ctx, svcCtx, username, 1)
- // 但 claims 还是旧的 tokenVersion=0。
- claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
- ud := &loaders.UserDetails{
- UserId: userId,
- Username: username,
- Status: 1,
- TokenVersion: 1, // 和 DB 一致;调用方上游会先看 claims != ud.TokenVersion 并 401,
- // 这里绕过上游直接走 helper 是为了验证 helper 自己也不会被旧 claims 蒙混过关。
- }
- _, err := RotateRefreshToken(ctx, svcCtx, claims, ud)
- require.ErrorIs(t, err, userModel.ErrTokenVersionMismatch,
- "claims.TokenVersion=0 但 DB=1,CAS 的 WHERE tokenVersion=0 命中 0 行,"+
- "helper 必须返回 ErrTokenVersionMismatch(调用方据此回 401/Unauthenticated)")
- u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, int64(1), u.TokenVersion,
- "CAS 失败时 DB.tokenVersion 不得被任何副作用推进,否则 helper 就成了"+
- "'只要过了 Parse 就一定 +1'的攻击 oracle")
- }
- // TC-1069: 目标 userId 不存在(已被删)→ RowsAffected=0 → ErrTokenVersionMismatch
- // 这条契约的意义:refreshToken 还没到过期但账号已被管理员删除的场景里,helper 不得把"找不到
- // 目标行"回溯到底层 sqlx 错误(例如 ErrNotFound)让上层误判成 500;必须统一回到可预测的
- // ErrTokenVersionMismatch 分支。
- func TestRotateRefreshToken_DeletedUser_Mismatch(t *testing.T) {
- ctx := context.Background()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- username := "r11_5_ghost_" + testutil.UniqueId()
- userId := insertRotateTestUser(t, ctx, svcCtx, username, 0)
- // 手动删除该行,构造"refresh token 还没过期但用户已消失"。
- _, err := testutil.GetTestSqlConn().ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", userId)
- require.NoError(t, err)
- claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
- ud := &loaders.UserDetails{
- UserId: userId, Username: username, Status: 1, TokenVersion: 0,
- }
- _, err = RotateRefreshToken(ctx, svcCtx, claims, ud)
- require.ErrorIs(t, err, userModel.ErrTokenVersionMismatch,
- "用户行已消失 → IncrementTokenVersionIfMatch RowsAffected=0,"+
- "helper 必须折叠成 ErrTokenVersionMismatch;不得回底层 sqlx 错误让上游误映射为 500")
- }
- // TC-1117: M-R14-1 —— RotateRefreshToken 的 post-commit UD 缓存清理必须跑在
- // DetachCacheCleanCtx 返回的独立 ctx 上,不得被请求 ctx 的 cancel/deadline 牵连。
- //
- // 若未 detach:当调用方使用的 `ctx` 在 `IncrementTokenVersionIfMatch` 提交后、
- // `UserDetailsLoader.Clean` 执行前被取消(HTTP deadline 到期 / 客户端断连),
- // Redis 的 DEL 会被 ctx cancel 中断,UD 缓存仍会携带旧 `tokenVersion` 长达 5min TTL,
- // 旧 access token 据此可继续通过中间件校验("token 复活")。
- //
- // 这里的断言口径:
- // 1. 以 `WithCancel(bg)` 作为 parent;
- // 2. 先预热 UD 缓存(确保 Redis 里真的有一条带旧 `TokenVersion=0` 的记录);
- // 3. 执行 `RotateRefreshToken` 成功,DB tokenVersion 0 → 1;
- // 4. 立即 `cancel(parent)` —— 模拟"请求在函数返回瞬间就 cancel";
- // 5. 下一次 `UserDetailsLoader.Load(userId, "")` 必须看到 `TokenVersion=1`(即
- // Clean 已跑完,Redis 没有残留旧 UD);
- // 6. 直接 `GetCtx` Redis 原 key 也必须已不存在(Clean 真正落到 Redis 了)。
- func TestRotateRefreshToken_M_R14_1_PostCommitCleanDetachedFromRequestCtx(t *testing.T) {
- parent, cancel := context.WithCancel(context.Background())
- defer cancel()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- username := "r14_detach_" + testutil.UniqueId()
- userId := insertRotateTestUser(t, parent, svcCtx, username, 0)
- // 用空 productCode 作为"跨产品的用户缓存"位点。Load 成功后,Redis 里落下一条
- // 键为 cacheKey(userId, "") 的 UD 记录,方便我们观察 Clean 后是否真的被抹除。
- udBefore, err := svcCtx.UserDetailsLoader.Load(parent, userId, "")
- require.NoError(t, err)
- require.NotNil(t, udBefore)
- assert.Equal(t, int64(0), udBefore.TokenVersion, "预热:初始 tokenVersion 必须是 0")
- cfg := testutil.GetTestConfig()
- rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
- rawKey := fmt.Sprintf("%s:ud:%d:%s", cfg.CacheRedis.KeyPrefix, userId, "")
- valBefore, err := rds.GetCtx(parent, rawKey)
- require.NoError(t, err)
- require.NotEmpty(t, valBefore,
- "预热后 Redis 必须落下一条 UD 正缓存,否则本用例无法观察到 Clean 的副作用")
- claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
- ud := &loaders.UserDetails{
- UserId: userId,
- Username: username,
- Status: 1,
- TokenVersion: 0,
- }
- tokens, err := RotateRefreshToken(parent, svcCtx, claims, ud)
- require.NoError(t, err, "DB CAS 必须成功")
- require.NotEmpty(t, tokens.RefreshToken)
- // 立即 cancel parent —— 模拟"HTTP 请求 ctx 在函数返回同时被 cancel"。若 Clean 没 detach,
- // 这次 cancel 已经来不及影响已经同步执行完的 Clean;但更严格的保护来自"Clean 运行时
- // 用的就是独立 ctx",该行为在本用例里由"Clean 落到 Redis 的效果"反向验证。
- cancel()
- u, err := svcCtx.SysUserModel.FindOne(context.Background(), userId)
- require.NoError(t, err)
- assert.Equal(t, int64(1), u.TokenVersion, "DB tokenVersion 必须 +1")
- // 关键断言 1:Redis 里的 UD 正缓存必须已被 Clean 抹掉。若退回"未 detach"实现,当
- // cancel 与 Clean 竞争时,这里经常残留旧值。
- valAfter, err := rds.GetCtx(context.Background(), rawKey)
- require.NoError(t, err)
- assert.Empty(t, valAfter,
- "M-R14-1:post-commit Clean 必须抹掉 UD 正缓存;若仍非空,说明 Clean 被请求 ctx cancel 拖死")
- // 关键断言 2:下一次 Load 必须打到 DB 并看到新 tokenVersion。
- udAfter, err := svcCtx.UserDetailsLoader.Load(context.Background(), userId, "")
- require.NoError(t, err)
- require.NotNil(t, udAfter)
- assert.Equal(t, int64(1), udAfter.TokenVersion,
- "M-R14-1:Clean 后 Load 必须重新打 DB 读到新 tokenVersion=1;若读到 0,"+
- "说明 Redis 仍持有旧 UD —— 对应生产旧 access token 在 5min TTL 内仍被中间件认可的复活窗口")
- }
|