diff --git a/docs/superpowers/specs/2026-06-12-change-password-design.md b/docs/superpowers/specs/2026-06-12-change-password-design.md index 687ec9d..a07cd1c 100644 --- a/docs/superpowers/specs/2026-06-12-change-password-design.md +++ b/docs/superpowers/specs/2026-06-12-change-password-design.md @@ -87,6 +87,7 @@ │ │ {old, new, verify_token} │ │ │ │ ├─────────────────────────→│ ─Dubbo→ │ │ │ │ │ UpdatePassword │ │ + │ │ │ - GetByID(uid) │ │ │ │ │ - VerifyToken() │ Get+DeleteVerifyTok│ │ │ │ ├───────────────────→│ │ │ │ - bcrypt 比对 │ │ @@ -166,6 +167,15 @@ func verifyTokenKey(scene, mobile string) string { - Register 仍用 `scene="register"`,改密用 `scene="password"`,两个 scene 的 Redis key 互不污染 - 老数据兼容:老的 `sms:register:*` / `verify:register:*` 在过渡期可保留(只要还有未过期的 token),过渡期后自然过期(60s/300s TTL) +**Call-site 审计(实施前必跑)**: + +```bash +grep -rn "SaveVerifyToken\|GetVerifyToken\|DeleteVerifyToken\|VerifyToken(\|SaveSMSCode\|GetSMSCode\|VerifyCode(\|SendCode(" \ + --include="*.go" backend/ +``` + +涉及文件(已通过 grep 确认):9 个 —— `auth_service.go / sms_service.go / user.proto / user.pb.go / auth_provider.go / unified_provider.go / sms_redis.go / auth_controller.go / user.triple.go`。其中 `*.proto` / `*.pb.go` / `*.triple.go` 是 regen 自动产物,人工改动集中在 `auth_service.go / sms_service.go / auth_provider.go / auth_controller.go / unified_provider.go`。 + ### 4.3 Service 层 UpdatePassword **修改** [backend/services/userService/service/user_service.go](backend/services/userService/service/user_service.go#L484-L587) @@ -262,7 +272,37 @@ var ( | 9 | Redis 故障 | mock `VerifyToken` error | wrap 后抛出 | | 10 | 事务回滚 | mock `tx.Updates` 失败 | 整事务回滚 | -Mock 方案:`testify/mock` 包装 `UserRepository`;`VerifyToken` 是包级函数,用 package-level option 注入避免改签名。 +Mock 方案: +- `UserRepository`:用 `testify/mock` 包装,实现 `UserRepository` 接口,注入到 `userService` +- `VerifyToken`(包级函数):用 package-level `var` 注入(标准 Go test 模式) + +```go +// sms_service.go(运行时) +var VerifyTokenFn func(ctx context.Context, scene, mobile, token string) error = realVerifyToken + +func VerifyToken(ctx context.Context, scene, mobile, token string) error { + return VerifyTokenFn(ctx, scene, mobile, token) +} + +func realVerifyToken(ctx context.Context, scene, mobile, token string) error { + // 实际 Get+DeleteVerifyToken 逻辑 +} + +// user_service_password_test.go(测试时) +func TestUpdatePassword_VerifyTokenExpired(t *testing.T) { + orig := VerifyTokenFn + defer func() { VerifyTokenFn = orig }() + VerifyTokenFn = func(ctx context.Context, scene, mobile, token string) error { + return errors.New("verify token expired") + } + // ... 断言 +} +``` + +**Test #9 错误类型规约**:当 `VerifyToken` 自身出错(Redis 故障、key 拼错等)时: +- `VerifyToken` 返回的原始 error → wrap 为 `fmt.Errorf("failed to verify token: %w", err)` → 在 Provider 层映射为 500 +- 业务上的"token 不存在 / 已过期" → 仍返回 `ErrInvalidVerifyToken` → 401 +- §7 错误码表中"其他 5xx"行即对应此场景,前端 toast 通用提示 --- @@ -487,6 +527,9 @@ const confirmChangePassword = async () => { if (res.code === 200) { uni.showToast({ title: '修改成功,请重新登录', icon: 'success' }) setTimeout(() => { + // 改密后清空所有登录态 + 注册临时数据 + // 备注:store 的 'user/logout' 实际只 commit CLEAR_AUTH(清 access_token/user/star_id/login_mobile/gallery_owner_id), + // 不会清 temp_register_*,所以这里需要手动清;后续若 store 升级,本处可同步收敛 store.dispatch('user/logout') uni.removeStorageSync('access_token') uni.removeStorageSync('user') @@ -520,7 +563,15 @@ import { validatePassword } from '@/utils/validator.js' // 已有 **API 函数兼容性确认**: - `sendCodeApi(mobile, scene)`:见 [api.js:187-196](frontend/utils/api.js#L187-L196),已支持任意 `scene` 字符串(后端在 SendCode 中按 scene 路由),无需改动 - `verifyCodeApi(mobile, code, scene)`:见 [api.js:199-209](frontend/utils/api.js#L199-L209),已支持任意 `scene`,无需改动 -- `updatePasswordApi(oldPassword, newPassword, verifyToken)`:**新签名**,§5.3.5 中给出实现;如有其他调用点需同步更新 +- `updatePasswordApi(oldPassword, newPassword, verifyToken)`:**新签名**,§5.3.5 中给出实现 + +**`updatePasswordApi` 调用点审计**(实施前必跑): + +```bash +grep -rn "updatePasswordApi" --include="*.vue" --include="*.js" frontend/ +``` + +经 grep 确认:仅 2 个调用点 —— `frontend/utils/api.js`(定义处)+ `frontend/pages/profile/profile.vue`(使用处)。本 PR 内一并更新即可,无连锁影响。 #### 5.3.5 API 函数扩展 @@ -629,13 +680,14 @@ export function updatePasswordApi(oldPassword, newPassword, verifyToken) { | 项 | 措施 | |---|---| +| 鉴权强制 | 路由 `/api/v1/account/password` 已挂 `AuthMiddleware`(见 [router.go:185-189](backend/gateway/router/router.go#L185-L189)),token 无效直接 401,无需在 Service 重复校验 userID 来源 | | 短信 token 一次性 | 复用 `DeleteVerifyToken` 消费后即删 | | 短信 token TTL | 5 分钟(与 Register 一致) | | 限流 | 复用 `sms:limit:mobile:` 计数器,scene=password | | 旧密码明文只传不存 | bcrypt(成本因子 10) | | 改密后清 token | 事务内 `access_token = nil` | | updated_at 同步更新 | 与 token 校验机制一致,旧 token 失效 | -| 不暴露 mobile 是否存在 | 短信发送成功/失败统一 200 | +| 不暴露 mobile 是否存在 | 短信发送成功/失败统一 200(继承 Register 的 anti-enumeration 行为) | ---