跳转至

Gateway 多设备认证与会话分页 Delta(2026-08-23)

设计意图

本变更把 gateway 一期的“device 即 user”开发捷径收敛为明确的 (tenant=0, user_id, device_id) 身份边界,并补齐已经在公网 protobuf 中定义、 但尚未接线的会话列表 keyset 分页。目标是让同用户的不同设备并存且都收到 在线推送;同一设备的新连接只替换它自己;令牌不能跨用户或设备复用;5000 个 会话的分页不会因排序变化而重复或漏项。

受影响模块与所有权

模块 文件 责任
wire qim-proto/proto/qim/up.proto、common.proto 在不复用字段号的前提下追加 AuthUp.user_id=6 与设备上限错误码。
gateway 认证/注册 qim-gateway/src/{auth,state,frames,client,push,config,main}.rs 令牌绑定、dev-only 签发、连接注册表、顶号与多设备推送。
gateway/session RPC qim-common/src/rpc.rs、qim-gateway/src/{frames,backend}.rs 完整透传会话分页请求和响应元数据。
session qim-session/src/{ctx,config,unread}.rs 受 TTL 约束的进程内快照、keyset continuation、显式重启。
开发负载工具 qim-loadgen/src/net.rs 显式 user/device 开发 token 请求与 AUTH。

不修改 SDK、客户端宿主、持久化消息/邮箱服务、根 Cargo 或项目记忆文件。

认证接口 Delta

  1. AuthUp 尾部追加 bytes user_id = 6(16 字节大端)。字段缺失仅为 legacy: gateway 令 user_id = device_id,且只接受 legacy token。显式字段与 device_id 都必须是非零、恰好 16 字节;不得把损坏字段解作 0。
  2. tenant 当前协议与内部 RPC 均锁为 0,但新版 token 的 MAC 输入包含此值:
v2.<64 lowercase hex>
HMAC-SHA256(secret,
    "qim-auth-v2\\0" || tenant_id:u128-be || user_id:u128-be || device_id:u128-be)

校验先严格解析格式和 hex,再使用 HMAC 校验 API 的常量时间比较。原 64 字符 hex (HMAC(secret, user_id)) 保留为 legacy 格式,且只在 user_id 缺失、 user_id == device_id 的请求中接受。任何新格式的 user/device 交叉复用均拒绝。 3. 开发 token 端点由 QIM_GATEWAY_MODE=development 与 QIM_GATEWAY_DEV_TOKEN_ENDPOINT=1 双重显式开启,并仅允许回环监听;请求固定为 0x02 || user_id(16B) || device_id(16B),返回 v2 token。生产模式要求非空 QIM_AUTH_SECRET,并拒绝开启该端点,避免默认 secret 或签发服务进入生产。 4. 连接注册表以 (user_id, device_id) 作为设备键,用户键仍保留全部连接用于 fanout。设备数上线为 QIM_MAX_DEVICES_PER_USER(默认 8);一期没有跨节点的 AuthService/Presence 权威,超限返回明确的 ERROR_CODE_DEVICE_LIMIT_EXCEEDED 而不伪称已实现“踢最旧持久设备”。同一 device 的替换在同一注册表临界区完成, 旧连接收到 KICKED{REPLACED,replaced_by_device=new_device_id} 后关闭。

会话分页接口 Delta

已有 wire 字段不变:

PULL_SESSION_LIST { snapshot_revision, page_cursor, limit }
SESSION_LIST_BATCH { sessions, snapshot_revision, next_page_cursor,
                     has_more, projection_complete }

gateway 将字段一字不改地映射到内部 Rpc。session 首次请求在进程内为用户载入 完整、稳定排序的会话 ID 快照(上限 5000),赋予进程内单调、重启不可重用的 snapshot_revision,并保留 snapshot_ttl=5 min。continuation 是服务端签名的 (revision, last_activity_id, conversation_id, cursor-MAC),不是可伪造的 offset;每一页 仅在冻结 ID 序列中定位该稳定排序键的后继,因此同 activity tie 也不会重复或漏项。

带旧 revision、错误 cursor 或过期 snapshot 的请求不尝试在变化的排序中续翻:服务端 建立/返回当前 revision 的第一页,并使 next_page_cursor 非空(若仍有下一页), 客户端据 revision 变化丢弃中间结果并重启。快照生命期内新活动可改变 canonical 摘要,但不会改变冻结的分页集合/顺序;结构性投影变更尚无此一期 Redis 输入,因而不 额外假装可检测。进程重启与 TTL 过期均走上述明确重启路径。

请求 limit=0 使用默认 50,任何大于 200 的值硬截为 200。空用户返回空 sessions、 has_more=false、当前 revision 与空 cursor。

数据流

AUTH{user,device,token}
  -> 严格 ID + 格式/常量时间 MAC 校验
  -> State::replace_device_and_register(user, device)
  -> user_conns(同用户所有设备) / device_conns(仅同设备替换)
  -> PUSH_EVENTS 广播到 user_conns

PULL_SESSION_LIST{revision,cursor,limit}
  -> gateway Rpc
  -> session Snapshot{revision, frozen conversation IDs, expires_at}
  -> keyset page + Rpc metadata
  -> gateway SessionListBatchDown 原样下行

风险与缓解

风险 缓解
旧客户端未带 user_id 仅保留 user=device 的 legacy 分支;不能用旧 token 取得任意新 device。
默认密钥/开发签发泄露到生产 production 缺 secret 失败;dev 签发需双开关且回环。
顶号与断开并发留下悬垂连接 单临界区同时摘除 user/device 索引,随后无锁下发 KICKED。
分页时排序变化 快照冻结集合与顺序;revision 不匹配/过期返回第一页,绝不静默 offset。
Session 进程重启 内存快照丢失被视为 revision 变化,客户端明确重启。

验收与测试计划

Must Have 验证
A1 v2 token 只对同 tenant/user/device 有效;legacy 仅 user=device;坏长度/hex 拒绝。
A2 同设备新登录只 KICK 旧同设备连接;同用户不同设备共存且均收到 PUSH。
A3 第 9 台在线设备收到明确 device-limit 错误,既有 8 台不受影响。
A4 生产没有 QIM_AUTH_SECRET 失败;开发 token 端点未双开关时不启动。
P1 limit 默认/上限正确,超过 2 页、相同 activity tie 的全集恰好一次。
P2 旧 revision、失效 cursor、过期快照均返回当前第一页而非错位续页;空/最后页元数据正确。
P3 gateway 请求和响应完整透传 revision/cursor/limit/has_more/projection_complete。
V cargo fmt --all、cargo test -p qim-proto -p qim-gateway -p qim-session -p qim-loadgen、相应 clippy 均通过。

上游接线点

  • SDK 代理应以 AuthUp.user_id=6 发送独立用户 ID,并使用 v2 token;旧 SDK 可临时 走 user=device legacy。
  • 生产 AuthService、session epoch、跨节点设备淘汰和 PresenceDirectory 尚未在本 delta 落地;当前 QIM_MAX_DEVICES_PER_USER 只约束本 gateway 实例的活跃设备。