Files
doodle-store/docs/v3.5/用户鉴权与登录设计.md
T

411 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Doodle Store v3.5 用户鉴权与登录设计
> 更新日期:2026-10-15。
>
> 状态:方案及分阶段实施设计。服务端微信登录、真实账户查询、GET /me、JWT reason、限流已接入;小程序 HTTP 接入、资料生命周期及 Web 会话待实现。当前落点见 [服务端登录接入说明](./服务端登录接入说明.md)。
>
> 依据:[数据库设计](./数据库设计文档.md)、[云服务端迁移方案](../云服务端迁移方案.md)、[Server 基础设置](./Server基础设置.md)及下述实际代码。第一阶段优先完成小程序;Web 在此基础上演进。
## 1. 设计结论与阶段边界
首期采用 **微信身份验证 → Doodle 用户 → 24 小时 JWT → 每次请求核对当前账户**。小程序继续静默登录,浏览和本地生成不等待登录;收藏、历史、资料、上传等远端用户操作需要有效凭证。
| 问题 | 本次建议 |
| ------------------ | ------------------------------------------------------------------------------------------ |
| 用户是谁 | `users.id` 是唯一业务账户标识;迁入用户保留原 `_id`,收藏及下载继续关联它 |
| 如何证明微信身份 | 小程序 `wx.login` 取得一次性 code,服务端用固定 AppID/AppSecret 调用 `code2Session` |
| 后续 API 如何认人 | `Authorization: Bearer <Doodle JWT>`;不信任缓存的 user、OpenID、客户端 UUID 或自报角色 |
| 登录有效期 | 沿用现有默认 24 小时;到期重新执行微信静默登录,不增加 refresh token |
| 如何撤销权限 | 每次查询当前 `status`、`role`、`authVersion`;敏感写事务中再次检查 |
| 是否增加数据库表 | 小程序阶段不增加,现有 13 张表足够;会话表和登录身份表按后续触发条件引入 |
| 昵称、头像、手机号 | 昵称和头像由用户主动填写;登录不要求填写,不要求绑定手机号 |
| Web 如何共用账户 | 优先考虑通过现有小程序扫码确认登录,直接进入同一 `users.id`;网站微信 OAuth 是独立接入选项 |
| Web 如何保存登录态 | 后续使用服务端会话及 HttpOnly Cookie,与小程序共用账户检查和业务权限规则 |
这里的 24 小时是 Doodle 凭证期限,与微信 `session_key` 的期限、本地资料缓存期限各自独立。`authVersion` 撤销的是旧凭证,不会禁止持有人再次通过微信证明身份;需要禁止登录时必须禁用账户。
暂不建设密码注册、短信验证码、复杂 RBAC、设备管理、第三方登录平台或统一 OAuth 服务。未来 Web 框架尚未选择,本方案不依赖 Next.js 或特定部署商。
## 2. 现状与需要补齐的部分
### 2.1 doodle-mini 实际实现
| 代码 | 已有行为 | 迁移时的处理 |
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| [app.ts](../../../doodle-mini/miniprogram/app.ts) | `onLaunch` 非阻塞调用 `getUser()`;仍初始化 CloudBase | 保留非阻塞启动,替换身份及业务请求;CloudBase 随兼容阶段退场 |
| [utils/auth.ts](../../../doodle-mini/miniprogram/utils/auth.ts) | 内存 → 7 天 Storage → `userLogin`;已有 `loginPromise` 合并部分并发 | 用户展示缓存与凭证分开;首次登录、续期、401 共用同一个登录 Promise |
| [userLogin](../../../doodle-mini/cloudfunctions/userLogin/index.js) | 从 `getWXContext()` 获取身份;按 OpenID 查用户;新增或更新活跃时间;支持更新资料 | 改成服务端兑换 code;唯一约束保证并发;登录与资料更新拆成明确接口 |
| [utils/http.ts](../../../doodle-mini/miniprogram/utils/http.ts) | 基础 `wx.request` 包装 | 补齐 API 地址、PATCH、Bearer、结构化错误、401 恢复;上传和下载另做适配 |
| [个人中心](../../../doodle-mini/miniprogram/pages/profile/profile.ts) | 已实现主动选择头像、填写昵称、上传 CloudBase,再更新资料 | 保留交互;头像改为上传七牛资产后绑定 assetId |
| [收藏](../../../doodle-mini/miniprogram/utils/favorites.ts)、[下载](../../../doodle-mini/miniprogram/utils/downloadLogs.ts) | 调用前 `getUser()`;仍调用云函数;部分失败返回空列表或 0 | 改为受保护 API;错误不能假装成真实空数据 |
| [设置页面](../../../doodle-mini/miniprogram/supportPages/settings/settings.ts)、[userSettings](../../../doodle-mini/cloudfunctions/userSettings/index.js) | 清资料或注销后 `clearUser()`;旧注销只删 users | 清资料不清凭证;注销完整事务清理,阻止后台自动重建账户 |
需要纠正的几个历史描述:
- 当前 user 对象包含 OpenID/UnionID,云函数还通过展开数据库记录返回资料;新 DTO 必须显式列字段,不直接返回数据库模型。
- 7 天缓存只控制重新调用登录的时机,内存命中还会跳过 Storage 过期检查;它不提供独立 API 的身份验证。
- 旧登录先查再新增,没有原子冲突处理,不能把它描述为天然并发安全的 upsert。
- 头像昵称已在个人中心实现;旧登录文档中的“后续实现”已过时。
- `envVersion === 'develop'` 只是入口显示条件。[homeTimerControl](../../../doodle-mini/cloudfunctions/homeTimerControl/index.js) 等入口代码未见管理员身份检查;线上调用权限仍需盘点,迁移后的每个管理 API 都必须自行校验。
### 2.2 doodle-store 已有基础
[src/auth](../../apps/server/src/auth/auth.module.ts) 已有 JWT 签发/验签、全局 `AccessTokenGuard`、`@Public()`、`@Roles()`、`@CurrentUser()`。JWT 固定 HS256,检查 issuer、audience、过期时间及 claims;角色取自账户读取结果。
`ACCOUNT_READER` 已替换为真实 Prisma 读取;数据库故障仍返回 503。微信登录、本人摘要和有限 reason 已接入;账户资料修改、注销及主动版本递增服务仍待实现。小程序恢复逻辑和 Web 内容继续作为后续目标设计。
## 3. 身份、凭证与权限分层
```mermaid
flowchart LR
WX[微信 code] --> Verify[服务端验证微信身份]
Verify --> Account[users.id · Doodle 账户]
Account --> Mini[小程序 Bearer JWT]
Account -.后续.-> Web[Web Cookie 会话]
Mini --> Check[当前账户状态与版本]
Web --> Check
Check --> Permission[角色及资源归属检查]
Permission --> Business[收藏 · 下载 · 资料 · 管理]
```
| 标识 | 用途 | 不承担的职责 |
| ------------- | -------------------------------------- | ---------------------------------------- |
| `users.id` | 全端稳定业务主键,业务关联及审计使用 | 不作为秘密或登录凭证 |
| 小程序 OpenID | 当前配置的小程序内识别微信身份 | 不假设与网站、公众号的 OpenID 相同 |
| UnionID | 同一微信开放平台账户下的关联线索,可空 | 首期不唯一、不自动合并账户 |
| `session_key` | 微信数据加解密需要的服务端密钥 | 不作为 Doodle Bearer,不下发客户端 |
| Doodle JWT | 小程序向本站 API 出示的访问凭证 | 不固化角色,不包含微信身份和用户资料 |
| 客户端 UUID | 现有埋点或本地安装标识 | 不用来认证、授权、找回账户或证明设备归属 |
服务层只接收经过守卫构造的可信用户上下文。公开 DTO 和客户端请求不能写入 `role/status/authVersion/openid/unionid`;即使请求者是管理员,资料更新接口也不接受这些字段。
## 4. 小程序登录流程
### 4.1 接口契约
所有路径省略 `/api/v1`。`POST /auth/wechat/login` 显式公开,仍受参数校验和限流保护。
```json
{
"code": "本次 wx.login 返回的临时凭证",
"mode": "resume"
}
```
`code` 必须为非空、有长度上限的字符串;不接受客户端 AppID、AppSecret、OpenID、userId 或角色。`mode` 必填,只有以下两种值:
- `resume`:只恢复现有账户,不创建用户。已有登录记录的启动、token 到期及 401 恢复都用它;身份对应账户已删除时返回 `ACCOUNT_NOT_FOUND`。
- `start`:允许首次建号。全新安装、没有任何账户历史时可以非阻塞静默调用;本机曾注销或发现账户被删除时,必须由用户再次点击“开始使用账户”后调用。
`mode` 是客户端的创建意图,不是安全凭证。两个模式都先验证微信身份、都拒绝 disabled 账户;`start` 不允许重置现有账户、解除禁用或选择目标 userId。
成功返回:
```json
{
"code": 0,
"message": "ok",
"data": {
"accessToken": "Doodle JWT",
"tokenType": "Bearer",
"expiresIn": 86400,
"expiresAt": "2026-10-16T02:00:00.000Z",
"user": {
"id": "沿用旧用户 ID 或新生成的 ID",
"nickName": null,
"avatarUrl": null,
"avatarUrlExpiresAt": null,
"createdAt": "2026-10-15T02:00:00.000Z"
}
}
}
```
不返回 `openid/unionid/session_key/authVersion` 或完整 users 记录。`avatarUrl` 非空且为私有签名地址时同时返回期限。统计走已有数量接口,不在每次登录中计数;小程序适配层将 `id` 转为现有页面需要的 `_id`,移除 UserInfo 中的微信身份字段。管理入口若需要角色,用受保护的 `GET /me` 获取当前 `role`,仅用于显示。
登录及本人资料响应设置 `Cache-Control: no-store`,不进入 CDN/公共响应缓存。JWT 示例中的期限取自实际签发结果,不能另算出与 `exp` 不一致的期限。
### 4.2 服务端步骤
1. 校验参数及入口限流,用服务端固定的小程序配置调用微信 `code2Session`;外部网络调用不放在数据库事务或行锁内。
2. 验证 HTTP/响应格式、微信错误码和非空 OpenID;成功时 `errcode` 可以不出现,出现则必须是成功值。UnionID 空串规范为 NULL。当前没有加解密用途,`session_key` 不落库、不缓存、不记录日志。
3. 开启短事务,按可信 OpenID 查询用户。存在则锁定用户并核对 active;不存在且 mode=resume 则结束并返回 `ACCOUNT_NOT_FOUND`;mode=start 才插入新账户。
4. 新账户由服务端生成 ID,默认 `role=user/status=active/authVersion=1`。用 `UNIQUE(openid)` 和明确的冲突处理兜底,并发失败时重新读取实际账户;不能覆盖其角色、状态、资料或版本。PostgreSQL 唯一冲突会中止事务,须用 `ON CONFLICT` 或回滚后的新事务重读,不能直接在失败事务里继续查询。
5. 已有账户更新 `last_active_at`;本次 UnionID 缺失不清空旧值,有值且旧值为空才补齐;两个非空值不一致时记录脱敏异常供核查,不覆盖、不合并。实际资料变化才维护相应 `updated_at`,不把普通登录当成权限或资料修改。
6. 提交后按本次确认的 `id/authVersion` 签发 JWT,显式构造 DTO。若提交后恰好发生禁用、注销或版本递增,旧快照 token 会在下一次账户核对时失效,不能跳过检查以迁就它。
被禁用的账户不能通过再次登录变成新用户。旧用户是否存在取决于已完成的数据导入;切换前必须迁入旧 users,不能先对空生产库开放 start 模式,再补导产生重复账户。
### 4.3 微信接口调用约束
微信小程序 code 有效期五分钟且只能使用一次;每次登录尝试现取现用。提交后超时也可能已经被微信消费,不使用同一个 code 盲目重试;客户端需要恢复时重新调用 `wx.login`,并受统一重试次数限制。[微信 wx.login](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.login.html)、[小程序登录说明](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html)。
| 配置/场景 | 建议行为 |
| ------------------------ | --------------------------------------------------------------------------------------------------------- |
| AppID/AppSecret | 仅服务端配置;新增 `WECHAT_MINI_APP_ID/WECHAT_MINI_APP_SECRET` 的启动校验,禁止读取客户端配置作为身份来源 |
| 外部 HTTP | 固定微信 HTTPS 地址,校验 TLS,建议单次超时 5 秒;不跟随到任意地址,不记录带 secret/code 的 URL |
| 微信 40029 或已使用 code | 返回 400 + `WECHAT_CODE_INVALID`;一次恢复周期内最多额外获取一次新 code |
| 微信 40226 | 返回 403 + `WECHAT_LOGIN_BLOCKED`;停止自动登录,不能伪装成普通 code 过期 |
| 微信 45011 / 本站限流 | 返回 429 + `RATE_LIMITED`;遵守 `Retry-After`,不立即循环 |
| 微信繁忙、超时、响应异常 | 返回 503 + `AUTH_UPSTREAM_UNAVAILABLE`;退避,允许公开功能继续使用 |
| AppID/AppSecret 配置失效 | 返回安全的服务不可用提示并告警;不引导客户端不断获取 code |
| 本站数据库失败 | 返回 503;不能改用客户端身份或只验 JWT 放行 |
具体错误映射以[微信 code2Session](https://developers.weixin.qq.com/miniprogram/dev/server/API/user-login/api_code2session.html)为准,不把微信原始 errmsg/响应直接下发。建议起步使用入口 IP 限流,例如单 IP 每分钟 30 次并保留小突发;共享网络需实测调参,不能只靠它。获得可信 OpenID 后补身份维度节流,成功登录不因客户端重试重复建号。首期单实例可用有界内存计数,重启会重置;多实例或严格配额时再选共享存储。
微信 `wx.checkSession` 检查微信的 `session_key`,不能检查 Doodle JWT。当前不使用微信加解密能力,不将它加入每次 API 请求流程。[微信 wx.checkSession](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.checkSession.html)。
## 5. 凭证、账户检查与业务授权
### 5.1 JWT
沿用现有 HS256、`iss=doodle-store`、`aud=doodle-mini`、`sub=userId`、`authVersion/iat/exp`,默认 `JWT_EXPIRES_IN_SECONDS=86400`。JWT 是可读取的签名载荷,里面不放用户资料、微信身份、手机号或秘密。
不提供任意 userId 换 token、前端自行续签、仅凭旧 token 换新 token 的接口。普通登录、更新资料、清资料不递增 `auth_version`,避免其他设备无故失效。
每次受保护请求顺序固定:
1. 从 Authorization 读取 Bearer,验证签名、算法、issuer、audience、期限及字段。
2. 真实 AccountReader 只查询当前 `id/status/role/authVersion`,核对用户存在、active、版本相同;数据库不可用则拒绝服务。
3. 将结果写入 `request.auth`,执行路由角色检查。
4. 业务服务检查目标归属和发布状态;写事务锁用户行后重新核对状态、版本,以及需要的管理员角色,再按数据库规定锁题型/内容。
第一阶段不缓存账户状态,确保禁用、降权、注销可在后续请求立即生效。读请求已通过检查后发生的并发状态变更,不宣称能撤回已发出的响应;关键写入与禁用/注销事务通过同一账户行锁串行化。管理事务也要检查操作者,不能只在控制器检查一次角色。
### 5.2 权限矩阵
| 接口/操作 | 未登录 | active 普通用户 | active 管理员 |
| ---------------------------------------------- | -------------- | -------------------------------- | -------------------------------------- |
| 首页、分类、active 功能及 active 内容列表/详情 | 允许 | 允许 | 允许,公共接口仍只看发布内容 |
| 登录 | 验证微信后允许 | 允许 | 允许,不因登录自动授权 |
| `/me`、收藏、下载历史及数量 | 拒绝 | 仅本人 | 仅本人 |
| 上传头像、修改/清除资料、注销 | 拒绝 | 仅本人 | 仅本人 |
| 管理查询、发布、上下架、配置、统计修订 | 拒绝 | 拒绝 | 明确管理路由允许,并按范围审计 |
| 读取其他用户私有头像、历史或收藏 | 拒绝 | 拒绝 | 默认拒绝;admin 不是全量私有数据通行证 |
| 未来服务端生成/私有文件 | 按专门规则 | 登录后仍核对配额、归属、资源状态 | 仍按对应业务规则 |
公开接口用 `@Public()` 明确声明,且不返回本人收藏状态;需要个人状态时单独请求 `/me/...`。现有 Public 装饰器会完全跳过守卫,不将它当作“有 token 就自动解析身份”的可选鉴权模式。
资产接口先验证 purpose、大小、格式、owner 和状态;头像只允许绑定本人拥有、用途为 avatar 且 ready 的私有资产。客户端传来的 assetId、worksheetId、resourceId 都必须做业务校验,不能仅因用户已登录便接受。
### 5.3 管理权限与版本撤销
首个管理员通过受控初始化脚本将已验证的现有账户升为 admin,不能按“第一个登录者”授权,也不能依赖 develop 环境。授权、降权、禁用、重新启用采用专用服务/脚本,在同一事务锁操作者与目标用户,按 ID 排序获取同层锁,写入审计;初始化脚本使用 system 操作者。
建议上述角色/状态变更都递增目标 `auth_version`,防止恢复 active 或重新授权后,早先的 token 再次恢复权限。仅撤销全部凭证时同样递增版本。避免整数溢出,变更失败不绕回旧版本。
首期没有独立 sessionId,因此不承诺单设备远端退出。小程序暂不增加普通“退出登录”按钮;本地清除缓存不等于服务端撤销凭证。若后续需要单设备退出、设备列表或管理员独立登录期限,再引入会话记录。未来后台的专用登录态还必须验证 `active+admin`,普通用户不能获得后台会话。
## 6. 小程序状态管理与 401 恢复
### 6.1 状态与缓存
`auth.ts` 对页面保留简单入口,但改变其内部含义:
| 方法 | 目标职责 |
| ----------------------- | ------------------------------------------------------------------------- |
| `getCachedUser()` | 只用于首屏展示,不能表示已经通过服务端认证 |
| `ensureAuthenticated()` | 获取尚未到期的凭证,或共用登录 Promise 完成微信登录 |
| `getUser()` | 兼容现有调用:先确保凭证,再返回当前用户摘要;不再仅凭 7 天缓存认定已登录 |
| `refreshUser()` | 已登录时 `GET /me` 刷新资料,必要时走一次正常认证恢复;不先清凭证 |
| `updateUserProfile()` | 请求受保护资料接口,成功后同步本地摘要和 globalData |
| `clearUser()` | 仅作为内部清理工具;明确清理原因,不能顺手触发重新注册 |
建议用带版本、环境及 AppID 命名空间的单个 Storage 对象保存 `{accessToken, expiresAt, user}`,避免多个 key 写入一半;读取时校验结构,旧 `user_info` 只可辅助展示,升级后移除。资料缓存不保存微信身份;角色缓存最多影响按钮显示。
凭证先内存、再 Storage;在到期前约 60 秒按需重新登录,`expiresIn` 从收到响应时估算本机剩余期,不依赖设备绝对时间完全准确。最终有效性始终由服务端判断;客户端若解析 JWT 只为时间提示,不能据此授权。上传/请求失败日志不得输出完整 token 或登录响应。
另存轻量的账户使用状态:是否曾建立账户、是否主动注销/暂停恢复。注销成功后即使 user 缓存被删除,也保留“需用户主动重新开始”的标记;否则下一次 `onLaunch` 会把它误认作全新安装。卸载清数据后该标记可能消失,此时重新验证微信可以建新账户;本方案不提供跨卸载的禁止再注册策略。
### 6.2 自动恢复规则
1. 公共请求不附带凭证、不启动登录。应用启动可非阻塞预热登录;失败不影响首页和已有本地生成/保存。
2. 受保护请求获取当前 token,记录发送时的 token 代次和业务所属 userId。登录请求直接使用底层 HTTP,不经过需登录的包装,避免递归。
3. 收到可恢复的 401 时,若已有较新 token,直接使用新 token 重试;不能让旧请求迟到的 401 清掉新凭证。否则共用唯一登录 Promise,以 mode=resume 恢复。
4. 一次原始业务请求只允许一次认证恢复和一次重发;恢复登录若遇到明确无效 code,可以额外获取一次新 code,之后停止。第二次业务 401、403、429、5xx 或网络错误不触发新的自动登录链。
5. 恢复后必须仍是原 userId,才能重发原业务写入。账户改变则中止、清理相关视图,由用户重新操作;下载补报队列绑定原 userId,不能代报到新账户。
6. GET、按目标定义的 PUT/DELETE 收藏可安全重发;下载使用原 exportId,资料更新复用同一内容。管理发布、通用 POST、资产上传、注销等不可通用重放,只由业务层按幂等键或专门流程恢复。超时不是“未提交”,不对未知结果的写入盲目重试。
7. 清理/注销时增加本地状态代次,使尚未结束的登录、资料及列表响应不能重新写回旧状态;等待中的业务请求同时取消或忽略,Promise 结束后只清理自身对应实例。
短时间登录失败保留退避状态,例如 5 秒、15 秒、60 秒并设置上限;页面并发请求共用同一次失败结果,不各自重新登录。网络恢复或用户点击重试可重新尝试;公开功能不受阻。
### 6.3 错误契约
保留 `{code:HTTP状态码, message, requestId}`,增加可选的枚举 `reason`;先更新服务端错误过滤器和契约,再接小程序。只允许预定义安全错误,不透传数据库异常或微信原始消息。
| HTTP / reason | 小程序行为 |
| --------------------------------------------- | --------------------------------------------------------------- |
| 401 `AUTH_REQUIRED/AUTH_EXPIRED/AUTH_INVALID` | 有账户恢复意图时合并静默恢复一次;匿名首次用户按 start 流程处理 |
| 401 `AUTH_REVOKED` | 清凭证并停止后台恢复;用户主动继续时重新验证微信,不能循环续签 |
| 401 `ACCOUNT_NOT_FOUND` | 清旧账户数据,停止自动建号;公开功能可用,用户主动开始才 start |
| 401 `ACCOUNT_DISABLED` | 清凭证并停止自动恢复;提示账户不可用,再次验证也不解除禁用 |
| 403 权限不足或微信登录被拦截 | 提示无权限/无法登录,不清正常资料后反复尝试 |
| 400/409 | 提示参数/业务冲突;不通过重登处理 |
| 429 | 遵守重试期限;请求层透传安全的 `Retry-After` 信息 |
| 503/网络错误 | 保留可用显示缓存,标明暂不可用;不返回伪造的空列表或零统计 |
认证错误原因只在已验签后的必要账户检查中细分,不能做成按客户端任意 userId 查询账户存在/状态的接口。未知 reason 保守停止自动恢复。
`wx.uploadFile` 也要附带凭证并解析 HTTP 状态;先确保登录再上传,不将 multipart 上传纳入通用自动重发。下载签名 URL 本身是短期访问能力,不把本站 Bearer 附给七牛或任意外部域名;私有 URL 到期后重新向受保护 API 获取地址。
## 7. 资料、清除、注销与重新进入
### 7.1 资料接口
| 接口 | 行为 |
| -------------------- | --------------------------------------------------------------------------------- |
| `GET /me` | 返回本人公开 DTO 及显示需要的当前 role;数量按独立接口查询;不输出 OpenID/UnionID |
| `PATCH /me/profile` | 仅允许 `nickName/avatarAssetId`;字段缺省是不修改,NULL 是清除;拒绝未知字段 |
| `DELETE /me/profile` | 清昵称及头像引用,保留账户、凭证、收藏、历史;前端更新摘要,不清登录态 |
| `DELETE /me` | 当前凭证 + 新微信 code + 用户确认,验证同一身份后按数据库规则完整注销 |
头像流程:`chooseAvatar` 得到临时本地文件 → 带 Bearer 上传 `POST /assets` → 得到本人 avatar assetId → PATCH 绑定。服务端校验归属,不能接受任意头像 URL、文件路径或他人 assetId。替换/清除后按既有文件清理机制处理旧私有对象,失败重试,不在数据库事务里请求对象存储。
昵称按数据库上限 100 个 Unicode 字符校验,首尾空白规范化,不把它作为唯一账号名。个人中心可分别保存昵称和头像,不强迫二者齐全;微信填写组件提供的临时路径不是永久头像地址。[微信头像昵称填写](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html)。
### 7.2 注销的完整边界
注销请求体建议为 `{code, confirm:true}`。前端在明确确认后现取 code;后端调用微信校验得到可信 OpenID,必须对应当前认证账户,不调用会自动建号的登录方法。code 不能替换 JWT 身份,也不能指定要删谁。
数据库步骤沿用已定规则:锁用户并重查版本/状态 → 按 ID 锁涉及的题型,仅扣功能收藏贡献 → 删除功能和内容两类收藏及下载记录 → 解除头像/私有资产关联并标记清理 → 去除管理审计中的身份及个人信息关联 → 删除用户,同一事务提交。之后所有旧 JWT 因查不到用户而失效。
客户端收到成功后清凭证、资料、本人列表和下载补报队列,阻止在途结果回写,记录暂停建号状态,回到可匿名浏览的首页。其他设备的 resume 登录查不到用户时也停止自动建号。用户主动重新开始时才创建全新 userId,旧收藏和历史不恢复。
若注销响应丢失,客户端进入“确认结果中”状态,暂停自动 start、原写请求及补报。可用原 token 查询 `GET /me`:`ACCOUNT_NOT_FOUND` 说明原账户已不存在,应执行本地清理;仍存在则让用户明确重试并获取新 code;网络失败则保持未知,不能当作成功或重新建号。注销接口不进入通用 401 重试器。
本方案通过 mode=resume 和客户端意图管理阻止无意重新注册,不承诺删除后永远禁止该微信再建号;跨设备的主动 start 仍可创建新账户。若未来要求注销冷静期或全局禁止再注册,需要另设计受控的身份保留策略,不能只靠已删除的 users 行实现。
## 8. 旧版本兼容与上线
旧版没有 `wx.login` code 或本站 JWT,兼容云函数仍从 `cloud.getWXContext()` 获取可信身份,经已有迁移方案规定的服务间签名入口访问同一业务服务。
- 兼容桥只允许已登记的小程序 AppID、函数/action 白名单、时间窗及防重放校验;客户端 event 中的身份一律不可信。
- 内部桥不伪装成普通 Public 业务路由。即使声明跳过 JWT,也必须由独立桥接守卫验签,再执行真实账户状态和服务层权限检查;签名密钥不能用于普通 JWT 签发。
- 旧登录 action 可以按既定兼容语义首次建号;其他收藏、下载、资料、设置 action 只查现有账户,不因账户已删而自动创建。桥接 action 和旧响应形状按白名单转换,不返回完整数据库模型。
- 旧版主动调用登录仍可能在注销后新建号,这是已发布代码的兼容限制;新用户 ID 不恢复旧数据。完整的客户端暂停建号体验只由新版保证,旧版下线期限按迁移观察确定。
- 历史资料响应如确有兼容字段需要,可在受控桥内返回已核实的必要字段;新版 DTO 不延续 OpenID/UnionID 暴露。
- 兼容桥仍需检查 admin,不能把“来自云函数”当成管理授权;旧管理页面要求使用新版。签名防重放记录必须原子且有清理期限,具体存储在兼容桥实施时落地。
上线顺序:先完成受控试迁与旧身份对账,再部署 API/兼容桥,在切换窗口统一写入源,最后发布新版。开发、体验、正式 API/缓存命名空间隔离;不得把不可信 envVersion 当成后端权限或测试库选择依据。体验版如仍对应同一 AppID,必须核实数据隔离和资源隔离策略。
## 9. 未来 Web:共用账户,独立管理会话
### 9.1 推荐首条路径:现有小程序扫码确认
Web 面向现有微信用户提供“使用涂鸦丫小程序登录”:网页展示带短期 challenge 的小程序码 → 微信扫码进入小程序登录确认页 → 小程序用当前有效 Doodle 身份明确确认 → 原网页兑换自己的 Web 会话。
这是项目自行实现的登录确认协议,不是直接调用网站应用微信 OAuth。它能直接使用已经验证的小程序 userId,不需要用两个 OpenID 猜测是否同一人;未来即使 UnionID 为空也能共用收藏和历史。微信官方提供小程序码进入指定页面的能力,登录 challenge、安全校验和浏览器会话由本站实现。[获取小程序码](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/qr-code.html)。
未来实现必须包含:
1. 网页创建挑战时建立 HttpOnly 的预登录浏览器绑定,服务端保存绑定摘要;二维码只携带随机定位符,不包含 token、userId 或浏览器兑换秘密。
2. challenge 建议 2 分钟过期,随机数至少 128 位,区分 user-web/admin 用途。小程序码图案本身可以长期存在,是否过期以本站 challenge 为准;过期扫码必须拒绝。
3. 确认页展示“将登录涂鸦丫网页”、使用的账户及双方可核对的短码,要求主动确认。扫码/打开页面本身不能完成登录,防止误扫和远程诱导登录。
4. 只有持有有效小程序凭证者可确认;只有创建挑战的浏览器绑定可查询/兑换结果。扫码定位符不能作为领取网页会话的凭证。
5. 状态原子流转 `pending → confirmed → consumed`,另有 rejected/expired;一次挑战只能成功兑换一次。兑换时重新核对用户状态和确认时的 authVersion,禁用/注销/撤销后不能再兑换。
6. 登录后轮换预登录标识,创建全新的服务端会话;轮询只取状态,不返回 Bearer;会话通过 `Set-Cookie` 交给原浏览器,不能经 URL、二维码或小程序转传。
此路径需要新增短期挑战存储,Web 上线时优先用 PostgreSQL 表和到期清理,不为低频轮询先引入 Redis/WebSocket。小程序码生成还需要微信服务端调用凭证管理,这与用户登录 JWT 不同,届时单独接入。
它主要解决电脑 Web 登录,不能覆盖所有手机浏览器或非微信用户。移动 Web、独立账号和非微信人群有实际需求后,再增加相应入口。
### 9.2 另一条路径:网站应用微信 OAuth
如果希望网页直接使用微信网站登录,可接入已审核的网站应用及其 AppID/AppSecret,使用授权码回调、服务端换身份和随机 state 验证。小程序 `wx.login` code 与网站授权 code 不能混用,微信 OAuth 的 access_token 也不是 Doodle API token。[网站应用微信登录开发指南](https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html)。
新增应用身份前,才引入 `auth_identities`:建议包含 `id/user_id/provider/app_id/subject/union_scope/union_id/created_at`,其中 `UNIQUE(provider, app_id, subject)`,subject 对应该应用可信 OpenID;`union_scope` 标明可信的开放平台账户范围,不能仅比较无作用域字符串。
届时的结构迁移顺序:
1. 将现有 `users.openid` 按服务端已核实的唯一小程序 AppID 回填成身份记录,逐项校验,保留原 userId。
2. 小程序和新增应用统一通过身份表找账户,稳定后再将 users 的旧身份列改为可空/过渡字段并停止作为权威源;最终移除。不能让只有网站身份的新账户仍被迫填写小程序 OpenID。
3. 已绑定身份直接进入原账户。新身份即使拿到相同 UnionID,也只作为绑定候选;按本项目规则要求用户显式验证并绑定,不自动合并已有账户。
4. 绑定必须有当前账户登录态 + 新身份的独立有效证明,不能由客户端上报 UnionID。身份已属于其他 userId 时返回冲突,不自动搬移收藏/历史;账号合并另立流程。
5. 注销时事务删除所有登录身份及会话。多个身份的唯一冲突和并发绑定必须依赖数据库约束,不能先查一次就认定安全。
UnionID 在同一微信开放平台账户范围内关联用户,且可能缺失;不是所有微信产品的全局账号 ID。[UnionID 机制](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html)。当前不恢复刚移除的 users.mini_app_id,也不提前改动已完成的 13 表基线。
### 9.3 Web 会话与浏览器安全
推荐 Web 与 API 通过同源 `/api` 反向代理访问,由 API 直接管理不透明会话 Cookie:`Secure; HttpOnly; SameSite=Lax; Path=/`,使用 `__Host-` 前缀且不设 Domain。不把长效凭证放在 localStorage、页面脚本变量、URL 或日志中。[OWASP 会话管理指南](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)。
Web 阶段新增 `auth_sessions`,保存随机会话秘密的摘要,而不是原始秘密;建议字段为 `id/token_hash/user_id/client_type/auth_version/created_at/last_seen_at/idle_expires_at/absolute_expires_at/revoked_at`。每次会话请求核对期限、撤销状态、账户状态和版本,角色仍取当前 users;注销清理会话,单浏览器退出只撤销当前会话。
普通 Web 初始可取 7 天空闲期限、30 天绝对期限;后台单独取 30 分钟空闲期限、8 小时绝对期限,并重新核对 active+admin。这些是未来上线时需要实测确认的起步值,不修改当前小程序 JWT 配置。
Cookie 接入时需要明确调整现有 Bearer-only 守卫:先按路由允许的凭证类型解析为统一用户上下文,再共用账户/版本/角色及归属检查。业务 API 可允许小程序 Bearer 或 Web session,专用后台登录/会话只接受 admin 用途;存在相互冲突的双凭证时拒绝,不在 Bearer 失效后悄悄改用另一身份 Cookie。小程序 audience 继续限定,不直接把 `doodle-mini` JWT 复制到网站。
Cookie 写请求必须有 CSRF 防护,建议 Origin 校验 + 与会话绑定的 CSRF token;OAuth state 和扫码 challenge 还需独立绑定原浏览器。SameSite、CORS、HttpOnly 都不能单独替代这些检查。只读 GET 不执行写操作;公开页面不夹带个人响应缓存。登录提升权限后更换会话标识,明确退出时撤销服务端会话。
当前 CORS 是 `credentials:false`。同源代理下不必为 Cookie 开放跨源;若未来必须跨源,需精确 Origin 白名单、credentials、Cookie SameSite/第三方 Cookie 限制及 CSRF 一并重新设计,不能只翻一个开关。前端框架和域名拓扑到 Web 开发时确定。
## 10. 数据库影响、落点与实施顺序
### 10.1 本次数据库无需变更
| 需求 | 当前结构是否足够 | 何时新增结构 |
| -------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------- |
| 单小程序登录、本人资料与权限 | 足够,users 的 OpenID 唯一约束、role/status/auth_version 已具备 | 当前无需 DDL |
| 全账户撤销、禁用及降权 | 足够,变更版本并实时读账户 | 当前无需 DDL |
| Web 小程序扫码登录 | users 足够,需挑战和浏览器会话存储 | Web 上线时增加 challenge/session 表 |
| 单设备远端退出、设备管理 | 当前不足 | 真实需求出现时引入会话标识及服务端会话检查 |
| 网站 OAuth、手机号、邮箱等多身份 | 当前 users.openid NOT NULL 不足 | 引入身份表并迁移 users 身份约束,不提前塞空串或伪 OpenID |
新表将来通过新的增量迁移接入,不重跑已有基线;身份/会话表进入正式数据库设计后,再实现相应能力。
### 10.2 代码落点
- `doodle-store/apps/server/src/auth`:扩展现有模块,负责登录控制器、微信客户端、JWT、账户读取、限流及认证错误;不再在 modules 下重复建第二套 auth。
- `doodle-store/apps/server/src/modules/users`:本人资料、资产绑定、清资料、注销及账户状态/角色变更,复用事务规则。
- `doodle-store/packages/contracts`:公开请求/响应、有限错误原因和用户 DTO;不包含 Prisma 模型、微信密钥或私有身份记录。doodle-mini 按 HTTP 契约接入,不跨仓库导源码。
- `doodle-mini/miniprogram/utils/auth.ts` 与业务 HTTP 层:缓存/凭证/状态机、并发合并、401 恢复;页面调用业务方法。
- 小程序个人中心、设置、收藏/历史、上传及现有管理入口:分别适配;不把迁移理解为只替换一个函数名。
### 10.3 建议推进顺序
1. Prisma schema/既有 SQL 基线登记、真实 AccountReader;将已有守卫测试接到真实账户查询。
2. 微信客户端与登录 API、resume/start 语义、显式 DTO、错误 reason、限流与日志脱敏。
3. 小程序统一认证请求层、缓存升级、并发/迟到响应处理;先打通体验版 `wx.login → API → GET /me`。
4. 本人资料、头像、清资料和注销;接入收藏/下载及幂等恢复,再补管理接口的事务内权限核对。
5. 兼容桥、迁入老身份样本、真机/离线/过期/注销验证,按迁移任务清单切换。
6. Web 实际启动时实现扫码挑战、Cookie 会话和对应数据库增量;新增网站身份时才迁移身份表。
真实微信联调前需核实 AppID/AppSecret、开放平台绑定情况、HTTPS 及 request/upload/download 合法域名、体验版数据隔离。现在无需把密钥写入设计文档,也不以这些待核实项阻塞方案评审。
## 11. 实施验收清单
以下是分阶段验收要求;已实施部分及自动验证范围见配套接入说明,不能将服务端测试视为小程序真机/Web 验收。
| 场景 | 预期结果 |
| ----------------------------------------- | --------------------------------------------------------------------------------------- |
| 老用户有效 code 登录 | userId 与旧 `_id` 相同,收藏/历史不断联 |
| 同 OpenID 并发首次 start | 只有一个 users,唯一冲突处理不覆盖权限/资料 |
| resume 查不到用户 | 不建号,返回 ACCOUNT_NOT_FOUND;需主动 start |
| 客户端伪造 AppID/OpenID/userId/role | DTO 拒绝或不作为可信身份,不影响目标账户 |
| code 无效/重复/过期、微信风控/超时/限流 | 有限且正确的错误映射,不创建用户、不无限重试 |
| JWT 伪造、过期、错误 audience/issuer/算法 | 拒绝;不接受无期限载荷或 URL token |
| token 有效但数据库不可用 | 503,不只验签放行 |
| 账户禁用、降权、撤销后重新启用 | 旧凭证仍无效;重新验证也不能绕过 disabled |
| 多个请求同时 401、旧 401 迟到 | 只发生一轮恢复;不会清除新凭证,每个业务最多重发一次 |
| 清缓存/注销与登录、列表请求并发 | 旧响应不复活状态;注销后后台 resume 不重新建号 |
| 重新登录返回不同 userId | 原写请求不重发,原下载队列不代报 |
| 保存成功后上报超时、重试 | 原 exportId 重用,不重复计数、不重复扣本地配额 |
| 清资料 | 收藏/历史及有效凭证保留,头像私有对象进入清理流程 |
| 注销成功或响应丢失 | 事务完整,旧 token 失效;未知结果可确认,不自动重放或重新注册 |
| 任意用户改他人头像、读他人历史 | 拒绝;管理员默认也不能读取全量私有数据 |
| 小程序本地改角色或显示管理按钮 | 管理 API 仍拒绝,管理写事务重新核对操作者 |
| 日志/接口/缓存检查 | 无 AppSecret、session_key、code、完整 token、原始微信身份泄漏 |
| iOS/Android 体验版、网络断开/恢复 | 正常静默登录;离线公开内容和本地生成可用,远端错误不显示成空数据 |
| Web 后续验收 | 挑战过期/重放/误扫/跨浏览器兑换拒绝;Cookie/CSRF/退出/账户撤销有效;与小程序同一 userId |
## 12. 参考与待确定事项
代码和数据库规则以本文引用的仓库文件为准。微信资料于 2026-10-15 查阅;未来接入时复核平台准入、配额及接口变化。
- [旧小程序登录方案](../../../doodle-mini/docs/用户登录与信息授权方案.md):仅作为云开发历史背景,实际行为以代码为准。
- [迁移任务清单](./迁移任务清单.md):真实开发、数据迁移与切换验收入口。
- [微信 code2Session](https://developers.weixin.qq.com/miniprogram/dev/server/API/user-login/api_code2session.html)、[UnionID](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html):身份来源与作用域。
- [微信网站应用登录](https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html)、[OWASP 会话管理](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html):未来网站登录和浏览器会话的参考,不代表本站已接入。
本次建议默认按“小程序 24 小时 JWT、暂不增加表、Web 优先现有小程序扫码确认”推进。Web 的移动端覆盖、网站应用准入及实际域名拓扑留到 Web 启动时确定;若改选独立手机号/邮箱登录,需要补充验证、凭证、找回和解绑方案,不能直接复用昵称或客户端 UUID。