| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162 |
- package auth
- import (
- "context"
- "database/sql"
- "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"
- )
- // ---------------------------------------------------------------------------
- // 覆盖目标:审计 L-R11-5 —— 把 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: L-R11-5 —— 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, "L-R11-5:预期 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,
- "L-R11-5:成功路径 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,
- "L-R11-5:新 refreshToken 承诺的 tokenVersion 必须等于 predictedVersion,"+
- "即 claims.TokenVersion + 1;若错位,接入方下一次刷新会立刻 401 失效")
- }
- // TC-1068: L-R11-5 —— 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,
- "L-R11-5: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,
- "L-R11-5:CAS 失败时 DB.tokenVersion 不得被任何副作用推进,否则 helper 就成了"+
- "'只要过了 Parse 就一定 +1'的攻击 oracle")
- }
- // TC-1069: L-R11-5 —— 目标 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,
- "L-R11-5:用户行已消失 → IncrementTokenVersionIfMatch RowsAffected=0,"+
- "helper 必须折叠成 ErrTokenVersionMismatch;不得回底层 sqlx 错误让上游误映射为 500")
- }
|