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

48 KiB
Raw Blame History

Doodle Store v3.5 用户鉴权与登录设计

更新日期:2026-10-15。

状态:方案及分阶段实施设计。服务端微信登录、真实账户查询、GET /me、JWT reason、限流已接入;小程序 HTTP 接入、资料生命周期及 Web 会话待实现。当前落点见 服务端登录接入说明。

依据:数据库设计、云服务端迁移方案、Server 基础设置及下述实际代码。第一阶段优先完成小程序;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 onLaunch 非阻塞调用 getUser();仍初始化 CloudBase 保留非阻塞启动,替换身份及业务请求;CloudBase 随兼容阶段退场
utils/auth.ts 内存 → 7 天 Storage → userLogin;已有 loginPromise 合并部分并发 用户展示缓存与凭证分开;首次登录、续期、401 共用同一个登录 Promise
userLogin 从 getWXContext() 获取身份;按 OpenID 查用户;新增或更新活跃时间;支持更新资料 改成服务端兑换 code;唯一约束保证并发;登录与资料更新拆成明确接口
utils/http.ts 基础 wx.request 包装 补齐 API 地址、PATCH、Bearer、结构化错误、401 恢复;上传和下载另做适配
个人中心 已实现主动选择头像、填写昵称、上传 CloudBase,再更新资料 保留交互;头像改为上传七牛资产后绑定 assetId
收藏、下载 调用前 getUser();仍调用云函数;部分失败返回空列表或 0 改为受保护 API;错误不能假装成真实空数据
设置页面、userSettings 清资料或注销后 clearUser();旧注销只删 users 清资料不清凭证;注销完整事务清理,阻止后台自动重建账户

需要纠正的几个历史描述:

  • 当前 user 对象包含 OpenID/UnionID,云函数还通过展开数据库记录返回资料;新 DTO 必须显式列字段,不直接返回数据库模型。
  • 7 天缓存只控制重新调用登录的时机,内存命中还会跳过 Storage 过期检查;它不提供独立 API 的身份验证。
  • 旧登录先查再新增,没有原子冲突处理,不能把它描述为天然并发安全的 upsert。
  • 头像昵称已在个人中心实现;旧登录文档中的“后续实现”已过时。
  • envVersion === 'develop' 只是入口显示条件。homeTimerControl 等入口代码未见管理员身份检查;线上调用权限仍需盘点,迁移后的每个管理 API 都必须自行校验。

2.2 doodle-store 已有基础

src/auth 已有 JWT 签发/验签、全局 AccessTokenGuard、@Public()、@Roles()、@CurrentUser()。JWT 固定 HS256,检查 issuer、audience、过期时间及 claims;角色取自账户读取结果。

ACCOUNT_READER 已替换为真实 Prisma 读取;数据库故障仍返回 503。微信登录、本人摘要和有限 reason 已接入;账户资料修改、注销及主动版本递增服务仍待实现。小程序恢复逻辑和 Web 内容继续作为后续目标设计。

3. 身份、凭证与权限分层

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 显式公开,仍受参数校验和限流保护。

{
    "code": "本次 wx.login 返回的临时凭证",
    "mode": "resume"
}

code 必须为非空、有长度上限的字符串;不接受客户端 AppID、AppSecret、OpenID、userId 或角色。mode 必填,只有以下两种值:

  • resume:只恢复现有账户,不创建用户。已有登录记录的启动、token 到期及 401 恢复都用它;身份对应账户已删除时返回 ACCOUNT_NOT_FOUND。
  • start:允许首次建号。全新安装、没有任何账户历史时可以非阻塞静默调用;本机曾注销或发现账户被删除时,必须由用户再次点击“开始使用账户”后调用。

mode 是客户端的创建意图,不是安全凭证。两个模式都先验证微信身份、都拒绝 disabled 账户;start 不允许重置现有账户、解除禁用或选择目标 userId。

成功返回:

{
    "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、小程序登录说明。

配置/场景 建议行为
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为准,不把微信原始 errmsg/响应直接下发。建议起步使用入口 IP 限流,例如单 IP 每分钟 30 次并保留小突发;共享网络需实测调参,不能只靠它。获得可信 OpenID 后补身份维度节流,成功登录不因客户端重试重复建号。首期单实例可用有界内存计数,重启会重置;多实例或严格配额时再选共享存储。

微信 wx.checkSession 检查微信的 session_key,不能检查 Doodle JWT。当前不使用微信加解密能力,不将它加入每次 API 请求流程。微信 wx.checkSession。

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 /me/avatar-upload → 得到本人 avatar assetId → PUT /me/profile 绑定。服务端校验归属,不能接受任意头像 URL、文件路径或他人 assetId。替换/清除后按既有文件清理机制处理旧头像对象,失败重试,不在数据库事务里请求对象存储。

昵称按数据库上限 100 个 Unicode 字符校验,首尾空白规范化,不把它作为唯一账号名。个人中心可分别保存昵称和头像,不强迫二者齐全;微信填写组件提供的临时路径不是永久头像地址。微信头像昵称填写。

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、安全校验和浏览器会话由本站实现。获取小程序码。

未来实现必须包含:

  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。网站应用微信登录开发指南。

新增应用身份前,才引入 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 机制。当前不恢复刚移除的 users.mini_app_id,也不提前改动已完成的 13 表基线。

9.3 Web 会话与浏览器安全

推荐 Web 与 API 通过同源 /api 反向代理访问,由 API 直接管理不透明会话 Cookie:Secure; HttpOnly; SameSite=Lax; Path=/,使用 __Host- 前缀且不设 Domain。不把长效凭证放在 localStorage、页面脚本变量、URL 或日志中。OWASP 会话管理指南。

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 查阅;未来接入时复核平台准入、配额及接口变化。

本次建议默认按“小程序 24 小时 JWT、暂不增加表、Web 优先现有小程序扫码确认”推进。Web 的移动端覆盖、网站应用准入及实际域名拓扑留到 Web 启动时确定;若改选独立手机号/邮箱登录,需要补充验证、凭证、找回和解绑方案,不能直接复用昵称或客户端 UUID。

小程序首期接入进度(2026-10-19)

小程序已接入 wx.login → POST /auth/wechat/login,启动/回前台非阻塞预热;utils/auth.ts 提供 getUser、ensureAuthenticated、getCachedUser、refreshUser 和内部清理入口。refreshUser 调用受保护的 GET /me。当前阶段没有将资料更新伪装成已迁移:资料编辑、头像上传、收藏、下载记录及账户设置仍由独立的 utils/legacy-auth.ts 兼容旧云业务,后续逐项切换。

新凭证通过 AppID 与完整 API baseUrl 隔离,保存版本化的账户历史、凭证、本机预计到期时间及白名单用户摘要。公开分类/题型请求显式不鉴权;其余请求默认获取并携带本站 JWT。首次登录、提前 60 秒续期和 401 恢复共用 Promise;一次业务请求最多恢复与重发一次,迟到的旧 401 复用较新凭证,恢复后账户改变则停止原请求。无效微信 code 最多重新获取一次,网络错误不会复用 code 或自动循环。

账号不存在/禁用会清理凭证并暂停自动恢复,账户历史标记不随会话清理丢失;个人中心提供用户主动重新开始的入口,服务端仍负责拒绝禁用账户。新后端摘要使用 globalData.user,旧云端使用 globalData.legacyUser,不混用两种用户 ID 或用旧缓存冒充 JWT。

前端专项及分类/预览回归共 23 项通过(真实 8800 联调用例未启用);真实微信 code 兑换需在开发者工具或真机验证。正式部署前仍需配置线上 API 域名并先迁移旧用户及关联数据,避免在未迁移的生产库创建另一套业务身份。

其余接口切换进度(2026-10-19)

已完成微信预拉取之外的云接口替换,旧 legacy-auth.ts 兼容层已移除。用户资料、收藏、下载、头像、管理发布/批次与首页动态接口均使用本站用户身份,详见小程序云接口迁移清单。前文“首期接入进度”是分阶段记录,当前实现以该清单为准;历史云端用户/收藏/下载数据尚未自动搬迁。