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¶
AuthUp尾部追加bytes user_id = 6(16 字节大端)。字段缺失仅为 legacy: gateway 令user_id = device_id,且只接受 legacy token。显式字段与device_id都必须是非零、恰好 16 字节;不得把损坏字段解作 0。- 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 实例的活跃设备。