浏览代码

feat: 密码修改接口返回结构调整

BaiLuoYan 5 小时之前
父节点
当前提交
58b3ea8db0

+ 8 - 6
README.md

@@ -959,20 +959,22 @@ Content-Type: application/json
 
 #### POST /api/auth/changePassword — 修改密码
 
-用户修改自己的登录密码。修改成功后递增 `tokenVersion`,所有已签发令牌即时失效,用户需重新登录。
+用户修改自己的登录密码。修改成功后递增 `tokenVersion` 并签发新的令牌对(accessToken + refreshToken),旧令牌即时失效,无需重新登录。
 
 **调用场景:**
 
-- **用户主动修改密码**:用户在个人设置页面修改密码
-- **首次登录强制改密**:管理员创建用户时设置了初始密码,用户首次登录后系统提示修改密码(前端检查 `mustChangePassword` 标志)
-- **密码泄露后重置**:用户怀疑密码泄露,通过修改密码使所有已签发令牌失效
+- **用户主动修改密码**:用户在个人设置页面修改密码,修改后自动更新本地 token,保持登录状态
+- **首次登录强制改密**:管理员创建用户后,用户首次登录时 `mustChangePassword=1`,前端跳转至修改密码页面,修改成功后 `mustChangePassword` 重置为 2
+- **密码泄露后重置**:用户怀疑密码泄露,通过修改密码使所有令牌失效
 
-**安全约束:** 必须验证原密码正确后才能修改;新密码需 6-72 字符且不能与旧密码相同。
+**安全约束:** 必须验证原密码正确后才能修改;新密码需 8-72 字符且不能与旧密码相同。
 
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
 | oldPassword | string | 是 | 原密码 |
-| newPassword | string | 是 | 新密码(6-72 字符,不能与旧密码相同) |
+| newPassword | string | 是 | 新密码(8-72 字符,不能与旧密码相同) |
+
+**响应 data:** `LoginResp`(含 accessToken、refreshToken、expires、userInfo),前端应立即用新令牌替换本地存储的旧令牌。
 
 #### POST /api/auth/updateInfo — 修改自身信息
 

+ 3 - 3
internal/handler/auth/changePasswordHandler.go

@@ -13,7 +13,7 @@ import (
 	"perms-system-server/internal/types"
 )
 
-// ChangePasswordHandler 修改密码接口。验证原密码后设置新密码,令牌即时失效
+// ChangePasswordHandler 修改密码接口。验证原密码后设置新密码,签发新令牌对,无需重新登录
 func ChangePasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		var req types.ChangePasswordReq
@@ -23,11 +23,11 @@ func ChangePasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
 		}
 
 		l := auth.NewChangePasswordLogic(r.Context(), svcCtx)
-		err := l.ChangePassword(&req)
+		resp, err := l.ChangePassword(&req)
 		if err != nil {
 			httpx.ErrorCtx(r.Context(), w, err)
 		} else {
-			httpx.OkJsonCtx(r.Context(), w, nil)
+			httpx.OkJsonCtx(r.Context(), w, resp)
 		}
 	}
 }

+ 64 - 12
internal/logic/auth/changePasswordLogic.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"time"
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
@@ -33,10 +34,10 @@ func NewChangePasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Ch
 	}
 }
 
-// ChangePassword 修改密码。已登录用户验证原密码后设置新密码,同时递增 tokenVersion 使所有已签发令牌失效,强制重新登录。
-func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error {
+// ChangePassword 修改密码。已登录用户验证原密码后设置新密码,递增 tokenVersion 并签发新令牌对,无需重新登录。
+func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) (*types.LoginResp, error) {
 	if msg := util.ValidatePassword(req.NewPassword); msg != "" {
-		return response.ErrBadRequest(msg)
+		return nil, response.ErrBadRequest(msg)
 	}
 
 	userId := middleware.GetUserId(l.ctx)
@@ -44,17 +45,17 @@ func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error
 	if l.svcCtx.TokenOpLimiter != nil {
 		code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("chpwd:%d", userId))
 		if code == limit.OverQuota {
-			return response.ErrTooManyRequests("操作过于频繁,请稍后再试")
+			return nil, response.ErrTooManyRequests("操作过于频繁,请稍后再试")
 		}
 	}
 
 	user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, userId)
 	if err != nil {
-		return response.ErrNotFound("用户不存在")
+		return nil, response.ErrNotFound("用户不存在")
 	}
 
 	if user.Status != consts.StatusEnabled {
-		return response.ErrForbidden("账号已被冻结")
+		return nil, response.ErrForbidden("账号已被冻结")
 	}
 
 	// 审计 L-R17-4:先做字符串等值比较(纳秒级),后做 bcrypt.CompareHashAndPassword
@@ -62,17 +63,17 @@ func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error
 	// bcrypt。Timing 差异(猜中 old=new 比猜错快 ~60ms)对攻击者几乎无用——能触发这条分支
 	// 的前提是攻击者已知正确的 oldPassword,此时 ChangePassword 本身已是 game over。
 	if req.OldPassword == req.NewPassword {
-		return response.ErrBadRequest("新密码不能与原密码相同")
+		return nil, response.ErrBadRequest("新密码不能与原密码相同")
 	}
 
 	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
 		logx.WithContext(l.ctx).Infof("change-password old-password mismatch userId=%d", userId)
-		return response.ErrBadRequest("原密码错误")
+		return nil, response.ErrBadRequest("原密码错误")
 	}
 
 	hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	// 审计 H-R11-1:把上面已经读到的 user.UpdateTime / user.Username 作为乐观锁 expected 透传;
@@ -84,9 +85,39 @@ func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error
 		// 把乐观锁失败显式映射成 409,避免 raw error 被 rest 框架兜成 500、前端错把"并发冲突"
 		// 当作系统故障处理,告警看板也不会把这类事件归到 5xx 噪声池。
 		if errors.Is(err, userModel.ErrUpdateConflict) {
-			return response.ErrConflict("密码已被其他会话修改,请刷新后重试")
+			return nil, response.ErrConflict("密码已被其他会话修改,请刷新后重试")
 		}
-		return err
+		return nil, err
+	}
+
+	// UpdatePassword 已将 tokenVersion 递增;新 tokenVersion = user.TokenVersion + 1。
+	// 先试签(签名失败不影响 DB 状态),再清缓存(detach ctx 保证独立于请求生命周期)。
+	newTokenVersion := user.TokenVersion + 1
+	ud := middleware.GetUserDetails(l.ctx)
+
+	productCode := ""
+	memberType := ""
+	if ud != nil {
+		productCode = ud.ProductCode
+		memberType = ud.MemberType
+	}
+
+	accessToken, err := GenerateAccessToken(
+		l.svcCtx.Config.Auth.AccessSecret,
+		l.svcCtx.Config.Auth.AccessExpire,
+		userId, user.Username, productCode, memberType, newTokenVersion,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	refreshToken, err := GenerateRefreshToken(
+		l.svcCtx.Config.Auth.RefreshSecret,
+		l.svcCtx.Config.Auth.RefreshExpire,
+		userId, productCode, newTokenVersion,
+	)
+	if err != nil {
+		return nil, err
 	}
 
 	// 审计 L-R13-5 方案 A:密码变更会同步递增 tokenVersion 使旧令牌失效;UD 缓存必须立即
@@ -95,5 +126,26 @@ func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error
 	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
 	defer cancel()
 	l.svcCtx.UserDetailsLoader.Clean(cleanCtx, userId)
-	return nil
+
+	userInfo := types.UserInfo{
+		UserId:             userId,
+		Username:           user.Username,
+		MustChangePassword: consts.MustChangePasswordNo,
+	}
+	if ud != nil {
+		userInfo.Nickname = ud.Nickname
+		userInfo.Avatar = ud.Avatar
+		userInfo.Email = ud.Email
+		userInfo.Phone = ud.Phone
+		userInfo.IsSuperAdmin = ud.IsSuperAdminRaw
+		userInfo.MemberType = ud.MemberType
+		userInfo.Perms = ud.Perms
+	}
+
+	return &types.LoginResp{
+		AccessToken:  accessToken,
+		RefreshToken: refreshToken,
+		Expires:      time.Now().Unix() + l.svcCtx.Config.Auth.AccessExpire,
+		UserInfo:     userInfo,
+	}, nil
 }

+ 27 - 19
internal/logic/auth/changePasswordLogic_test.go

@@ -64,11 +64,15 @@ func TestChangePassword_Success(t *testing.T) {
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
 	logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
-	err := logic.ChangePassword(&types.ChangePasswordReq{
+	resp, err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: oldPwd,
 		NewPassword: newPwd,
 	})
 	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.NotEmpty(t, resp.AccessToken)
+	assert.NotEmpty(t, resp.RefreshToken)
+	assert.Greater(t, resp.Expires, int64(0))
 
 	updated, err := svcCtx.SysUserModel.FindOne(ctx, userId)
 	require.NoError(t, err)
@@ -89,7 +93,7 @@ func TestChangePassword_MustChangePasswordReset(t *testing.T) {
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
 	logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
-	err := logic.ChangePassword(&types.ChangePasswordReq{
+	_, err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: oldPwd,
 		NewPassword: newPwd,
 	})
@@ -112,7 +116,7 @@ func TestChangePassword_WrongOldPassword(t *testing.T) {
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
 	logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
-	err := logic.ChangePassword(&types.ChangePasswordReq{
+	_, err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: "Wrongpass1",
 		NewPassword: "Newpass456",
 	})
@@ -128,7 +132,7 @@ func TestChangePassword_NewPasswordTooShort(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 
 	logic := NewChangePasswordLogic(ctxWithUserId(1), svcCtx)
-	err := logic.ChangePassword(&types.ChangePasswordReq{
+	_, err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: "oldpass",
 		NewPassword: "Pas1234",
 	})
@@ -153,7 +157,7 @@ func TestChangePassword_NewPasswordExactly8Chars(t *testing.T) {
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
 	logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
-	err := logic.ChangePassword(&types.ChangePasswordReq{
+	_, err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: oldPwd,
 		NewPassword: newPwd,
 	})
@@ -169,7 +173,7 @@ func TestChangePassword_NewPasswordEmpty(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 
 	logic := NewChangePasswordLogic(ctxWithUserId(1), svcCtx)
-	err := logic.ChangePassword(&types.ChangePasswordReq{
+	_, err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: "oldpass",
 		NewPassword: "",
 	})
@@ -186,7 +190,7 @@ func TestChangePassword_NewPasswordTooLong(t *testing.T) {
 
 	longPwd := "A" + strings.Repeat("a", 71) + "1"
 	logic := NewChangePasswordLogic(ctxWithUserId(1), svcCtx)
-	err := logic.ChangePassword(&types.ChangePasswordReq{
+	_, err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: "oldpass",
 		NewPassword: longPwd,
 	})
@@ -211,7 +215,7 @@ func TestChangePassword_NewPasswordExactly72Chars(t *testing.T) {
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
 	logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
-	err := logic.ChangePassword(&types.ChangePasswordReq{
+	_, err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: oldPwd,
 		NewPassword: newPwd,
 	})
@@ -235,7 +239,7 @@ func TestChangePassword_SameOldAndNew(t *testing.T) {
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
 	logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
-	err := logic.ChangePassword(&types.ChangePasswordReq{
+	_, err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: pwd,
 		NewPassword: pwd,
 	})
@@ -282,7 +286,7 @@ func TestChangePassword_SameOldAndNew_ChecksBeforeBcrypt(t *testing.T) {
 	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
 
 	wrongOldButEqualNew := "Samepass123"
-	err = NewChangePasswordLogic(ctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
+	_, err = NewChangePasswordLogic(ctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
 		OldPassword: wrongOldButEqualNew,
 		NewPassword: wrongOldButEqualNew,
 	})
@@ -301,7 +305,7 @@ func TestChangePassword_UserNotFound(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 
 	logic := NewChangePasswordLogic(ctxWithUserId(99999999), svcCtx)
-	err := logic.ChangePassword(&types.ChangePasswordReq{
+	_, err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: "Oldpass123",
 		NewPassword: "Newpass456",
 	})
@@ -341,7 +345,7 @@ func TestChangePassword_UpdateConflict_Maps409(t *testing.T) {
 	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
 
 	logic := NewChangePasswordLogic(ctx, svcCtx)
-	err = logic.ChangePassword(&types.ChangePasswordReq{
+	_, err = logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: oldPwd,
 		NewPassword: newPwd,
 	})
@@ -382,7 +386,7 @@ func TestChangePassword_GenericUpdateError_StillPropagates(t *testing.T) {
 	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
 
 	logic := NewChangePasswordLogic(ctx, svcCtx)
-	err = logic.ChangePassword(&types.ChangePasswordReq{
+	_, err = logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: oldPwd,
 		NewPassword: newPwd,
 	})
@@ -438,12 +442,15 @@ func TestChangePassword_E2E_SecondCallWithOldPwd_Maps400(t *testing.T) {
 		&loaders.UserDetails{UserId: userId, Username: username, Status: 1})
 
 	require.NoError(t,
-		NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
-			OldPassword: oldPwd, NewPassword: "NewpassX_11",
-		}),
+		func() error {
+			_, err := NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
+				OldPassword: oldPwd, NewPassword: "NewpassX_11",
+			})
+			return err
+		}(),
 		"首改必须成功")
 
-	err := NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
+	_, err := NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
 		OldPassword: oldPwd, NewPassword: "NewpassY_22",
 	})
 	require.Error(t, err)
@@ -502,6 +509,7 @@ func runSnapshotForwardCase(t *testing.T, expectedUpdateTime int64) {
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
 	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
 
-	require.NoError(t, NewChangePasswordLogic(ctx, svcCtx).ChangePassword(
-		&types.ChangePasswordReq{OldPassword: oldPwd, NewPassword: newPwd}))
+	_, err = NewChangePasswordLogic(ctx, svcCtx).ChangePassword(
+		&types.ChangePasswordReq{OldPassword: oldPwd, NewPassword: newPwd})
+	require.NoError(t, err)
 }

+ 2 - 2
perm.api

@@ -476,9 +476,9 @@ service perm-api {
 	@handler UserInfoHandler
 	post /auth/userInfo returns (UserInfo)
 
-	// ChangePassword 修改密码。已登录用户验证原密码后设置新密码,同时递增 tokenVersion 使所有已签发令牌失效
+	// ChangePassword 修改密码。已登录用户验证原密码后设置新密码,同时递增 tokenVersion 并签发新令牌对,无需重新登录
 	@handler ChangePassword
-	post /auth/changePassword (ChangePasswordReq)
+	post /auth/changePassword (ChangePasswordReq) returns (LoginResp)
 
 	// Logout 用户注销。递增 tokenVersion 使所有已签发的 access/refresh 令牌立即失效,并清除用户缓存
 	@handler Logout

+ 6 - 4
test-design.md

@@ -208,14 +208,14 @@ MySQL (InnoDB) + Redis Cache
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
-| TC-0054 | POST /api/auth/changePassword | 正常修改 | `{"oldPassword":"123456","newPassword":"654321"}` | code=0 | 正常路径 | P0 | changePasswordLogic全路径 |
+| TC-0054 | POST /api/auth/changePassword | 正常修改 | `{"oldPassword":"123456","newPassword":"654321"}` | code=0;返回 accessToken/refreshToken/expires;DB 密码已更新 | 正常路径 | P0 | changePasswordLogic全路径;签发新令牌对 |
 | TC-0055 | POST /api/auth/changePassword | mustChangePassword重置 | 正常修改后 | DB中mustChangePassword=2 | 功能验证 | P0 | user.MustChangePassword=2 |
 | TC-0056 | POST /api/auth/changePassword | 原密码错误 | `{"oldPassword":"wrong","newPassword":"newpwd"}` | code=400, "原密码错误" | 异常路径 | P0 | bcrypt失败 |
 | TC-0057 | POST /api/auth/changePassword | 新密码少于8字符 | `{"oldPassword":"old","newPassword":"Pas1234"}` | code=400, "密码长度不能少于8个字符" | 输入校验 | P0 | len<8 |
-| TC-0058 | POST /api/auth/changePassword | 新密码恰好8字符(含大小写+数字) | `{"oldPassword":"old","newPassword":"Abcdef1x"}` | code=0 | 边界 | P1 | len==8,含大小写+数字 |
+| TC-0058 | POST /api/auth/changePassword | 新密码恰好8字符(含大小写+数字) | `{"oldPassword":"old","newPassword":"Abcdef1x"}` | code=0;返回新令牌对 | 边界 | P1 | len==8,含大小写+数字 |
 | TC-0059 | POST /api/auth/changePassword | 新密码空字符串 | `{"oldPassword":"old","newPassword":""}` | code=400 | 边界 | P0 | len("")=0<8 |
 | TC-0060 | POST /api/auth/changePassword | 新密码超过72字符 | `{"oldPassword":"old","newPassword":"a*73"}` | code=400, "密码长度不能超过72个字符" | 输入校验 | P0 | len>72 |
-| TC-0061 | POST /api/auth/changePassword | 新密码恰好72字符 | `{"oldPassword":"old","newPassword":"a*72"}` | code=0 | 边界 | P1 | len==72 |
+| TC-0061 | POST /api/auth/changePassword | 新密码恰好72字符 | `{"oldPassword":"old","newPassword":"a*72"}` | code=0;返回新令牌对 | 边界 | P1 | len==72 |
 | TC-0062 | POST /api/auth/changePassword | 新旧密码相同 | `{"oldPassword":"123456","newPassword":"123456"}` | code=400, "新密码不能与原密码相同" | 输入校验 | P0 | OldPassword==NewPassword |
 | TC-0063 | POST /api/auth/changePassword | 用户不存在 | token中userId已删除 | code=404 | 异常路径 | P1 | FindOne失败 |
 | TC-0769 | POST /api/auth/changePassword | ChangePassword 超过 TokenOpLimiter 配额 | 同一 userId 连续调用超限 | 429 "操作过于频繁,请稍后再试" | 安全/限流 | P0 | `TokenOpLimiter.Take("chpwd:%d")` |
@@ -229,6 +229,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-1042 | POST /api/auth/changePassword | Logic 层 E2E:同一 user 连续两次用 "同一旧密码 P0" 发起 ChangePassword,第二次必须 400 "旧密码错误" | 第一次 P0→P1(200),第二次仍送 oldPass=P0 | 第二次 `CodeError.Code()==400`,msg 含 "旧密码错误";**不得**成 409(否则 400/409 语义混淆) | 边界 | P0 | 400/409 分桶契约 |
 | TC-1043 | POST /api/auth/changePassword | Logic 层 mock:ChangePassword 必须把"外层 FindOne 拿到的 user.UpdateTime" 原封不动透传给 Model 层 | mock `UpdatePassword(id, username, _, MustChangePasswordNo, snapshotUpdateTime)`,断言第 5 个实参 | mock EXPECT 命中;若回退为"Model 内部再读 updateTime",这里会拿到零值触发失败 | 契约 | P0 | CAS 快照来源契约 |
 | TC-1179 | POST /api/auth/changePassword | "新旧密码相同"校验必须排在 `bcrypt.CompareHashAndPassword` 之前 | 用假冒 OldPassword(bcrypt 比对会失败),但 NewPassword 与 OldPassword 字面相等;请求落 `ChangePassword` | `CodeError.Code()==400`;文案精确等于 "新密码不能与原密码相同"(若文案是"原密码错误"说明顺序被误回滚);`UpdatePassword` 绝不被调用 | 性能/契约 | P1 | 廉价字符串判等要跑在昂贵 bcrypt 之前,防止攻击者借 OldPassword 长度/并发把 bcrypt 放大成 CPU DoS;也把"用户输入了同一个新密码"早期吐回,避免无谓的 hash |
+| TC-1300 | POST /api/auth/changePassword | 修改成功后返回新令牌对(accessToken/refreshToken/expires 非空) | 正常修改 | resp.AccessToken 非空;resp.RefreshToken 非空;resp.Expires > 0;新 token 可通过 JWT 解析且 tokenVersion = oldVersion+1 | 功能验证 | P0 | 签发新令牌对,无需重新登录 |
+| TC-1301 | POST /api/auth/changePassword | 修改成功后旧 token 立即失效 | 修改成功后用旧 accessToken 访问受保护接口 | 401 Unauthorized(tokenVersion 不匹配) | 安全 | P0 | tokenVersion 递增使旧令牌失效 |
 
 ### 2.6 创建产品 `POST /api/product/create`
 
@@ -1484,7 +1486,7 @@ MySQL (InnoDB) + Redis Cache
 ## 用户凭证票据机制(CreateUser / ResetPassword / FetchUserCredentials)
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
-|:---|:---|:---|:---|:---|:---|:---|:---|
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
 | TC-1280 | POST /api/user/create | 正常创建用户,返回ticket | SuperAdminCtx + valid username/deptId | code=200, resp含id/credentialsTicket/credentialsExpiresAt | 正常路径 | P0 | 服务端生成密码+ticket |
 | TC-1281 | POST /api/user/create | 用ticket领取凭证后可登录 | 创建→fetchCredentials→用返回密码登录 | 登录成功 | 端到端 | P0 | 密码可用性验证 |
 | TC-1282 | POST /api/user/create | 生成密码满足强度要求 | 创建→fetchCredentials→ValidatePassword | ValidatePassword返回"" | 功能验证 | P0 | 密码强度 |

+ 3 - 1
test-report.md

@@ -221,7 +221,7 @@
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
-| TC-0054 | 正常修改 | ✅ pass |
+| TC-0054 | 正常修改(返回 accessToken/refreshToken/expires) | ✅ pass |
 | TC-0055 | mustChangePassword重置 | ✅ pass |
 | TC-0056 | 原密码错误 | ✅ pass |
 | TC-0057 | 新密码少于8字符 | ✅ pass |
@@ -242,6 +242,8 @@
 | TC-1042 | Logic 层 E2E:同一 user 连续两次用 "同一旧密码 P0" 发起 ChangePassword,第二次必须 400 "旧密码错误" | ✅ pass |
 | TC-1043 | Logic 层 mock:ChangePassword 必须把"外层 FindOne 拿到的 user.UpdateTime" 原封不动透传给 Model 层 | ✅ pass |
 | TC-1179 | `OldPassword==NewPassword` 必须在 `bcrypt.CompareHashAndPassword` 之前被拦截(L-R17-4) | ✅ pass |
+| TC-1300 | 修改成功后返回新令牌对(accessToken/refreshToken/expires 非空) | ✅ pass |
+| TC-1301 | 修改成功后旧 token 立即失效(tokenVersion 递增) | ✅ pass |
 
 ### 2.6 创建产品 `POST /api/product/create`