From fcaf7bdb3809a15346de93b4262a085b3bc4681a Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 21 Apr 2026 16:25:45 +0800 Subject: [PATCH] feat: add multi-device session identity --- .../01_M0_M2_FOUNDATION_AND_AUTH.md | 5 +- .../AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md | 184 ++++++++ ...VICE_SESSION_IDENTITY_DESIGN_2026-04-21.md | 309 +++++++++++++ docs/technical/README.md | 2 + ...REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md | 53 ++- packages/shared/src/contracts/auth.ts | 5 + server-rs/crates/api-server/src/app.rs | 128 ++++++ .../crates/api-server/src/auth_session.rs | 13 +- .../crates/api-server/src/auth_sessions.rs | 113 +++++ server-rs/crates/api-server/src/main.rs | 4 +- .../crates/api-server/src/password_entry.rs | 5 +- .../crates/api-server/src/session_client.rs | 433 ++++++++++++++++++ server-rs/crates/module-auth/src/lib.rs | 236 ++++++++-- 13 files changed, 1445 insertions(+), 45 deletions(-) create mode 100644 docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md create mode 100644 docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md create mode 100644 server-rs/crates/api-server/src/auth_sessions.rs create mode 100644 server-rs/crates/api-server/src/session_client.rs diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md index 926f1d61..e6f319a0 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -169,6 +169,8 @@ 交付物:[../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/config.rs](../server-rs/crates/api-server/src/config.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) - [x] 实现 refresh token 轮换 交付物:[../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) +- [x] 实现多端会话身份建模与会话列表查询 + 交付物:[../docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](../docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md)、[../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)、[../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/session_client.rs](../server-rs/crates/api-server/src/session_client.rs)、[../server-rs/crates/api-server/src/auth_sessions.rs](../server-rs/crates/api-server/src/auth_sessions.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../packages/shared/src/contracts/auth.ts](../packages/shared/src/contracts/auth.ts) - [x] 实现会话吊销 交付物:[../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/logout.rs](../server-rs/crates/api-server/src/logout.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) - [ ] 实现全端登出 @@ -224,7 +226,8 @@ - [ ] 兼容 `/api/auth/logout-all` - [x] 兼容 `/api/auth/refresh` 交付物:[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [ ] 兼容 `/api/auth/sessions` +- [x] 兼容 `/api/auth/sessions` + 交付物:[../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/auth_sessions.rs](../server-rs/crates/api-server/src/auth_sessions.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs) - [ ] 兼容 `/api/auth/sessions/:sessionId/revoke` - [ ] 兼容 `/api/auth/audit-logs` - [ ] 兼容 `/api/auth/risk-blocks` diff --git a/docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md b/docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md new file mode 100644 index 00000000..9e860fdf --- /dev/null +++ b/docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md @@ -0,0 +1,184 @@ +# `/api/auth/sessions` 会话列表与多端标识查询设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于指导 `M2` 中 `兼容 /api/auth/sessions` 的首版落地,冻结: + +1. `GET /api/auth/sessions` 的请求与响应 contract +2. 当前设备识别方式与 `isCurrent` 语义 +3. 多端登录识别字段如何从 `refresh_session` 派生到 DTO +4. Rust 首版在 Axum + 进程内 `module-auth` 下的最小实现边界 + +## 2. 当前基线 + +当前 Node `/api/auth/sessions` 已具备以下稳定行为: + +1. 依赖 Bearer JWT 确认用户身份 +2. 从 refresh cookie 识别当前设备 +3. 返回当前账号全部未吊销活跃会话 +4. 每条记录给出端侧标签、最近活跃时间、到期时间、IP 脱敏信息与是否当前设备 + +当前问题是: + +1. 旧实现只能粗略给出“网页端浏览器 / 移动端浏览器” +2. 无法稳定区分同设备不同浏览器 +3. 无法区分微信内 H5 与微信小程序、小程序平台来源 + +因此本次 `/api/auth/sessions` 首版落地必须直接承接多端会话身份模型。 + +## 3. 设计输入 + +本任务直接受以下文档约束: + +1. [MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](./MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md) +2. [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md) +3. [AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](./AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md) +4. [AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](./AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md) + +## 4. 首版落地范围 + +本阶段只落以下内容: + +1. `module-auth` 提供按 `user_id` 读取活跃 refresh session 列表的能力 +2. `api-server` 暴露 `GET /api/auth/sessions` +3. 登录创建 session 时落库结构化客户端身份字段 +4. 会话列表返回多端识别所需字段,并兼容旧 `clientLabel` + +本阶段明确不包含: + +1. `/api/auth/sessions/:sessionId/revoke` +2. 前端完整消费全部新增字段 +3. SpacetimeDB reducer / view 正式读表 + +## 5. 请求与响应 contract + +### 5.1 请求 + +1. 方法:`GET` +2. 路径:`/api/auth/sessions` +3. 请求体:空 +4. 鉴权: + - Bearer JWT 必填 + - refresh cookie 选填但建议携带,用于判断 `isCurrent` + +### 5.2 成功响应 + +```json +{ + "sessions": [ + { + "sessionId": "usess_xxx", + "clientType": "web_browser", + "clientRuntime": "chrome", + "clientPlatform": "windows", + "clientLabel": "Windows / Chrome", + "deviceDisplayName": "Windows / Chrome", + "miniProgramAppId": null, + "miniProgramEnv": null, + "userAgent": "Mozilla/5.0 ...", + "ipMasked": "203.0.*.*", + "isCurrent": true, + "createdAt": "2026-04-21T10:00:00Z", + "lastSeenAt": "2026-04-21T10:05:00Z", + "expiresAt": "2026-05-21T10:00:00Z" + } + ] +} +``` + +字段说明: + +1. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致 +2. `clientRuntime`、`clientPlatform`、`deviceDisplayName` 是多端识别首版最小新增字段 +3. 小程序来源额外暴露 `miniProgramAppId`、`miniProgramEnv` + +### 5.3 失败响应 + +以下情况返回 `401 UNAUTHORIZED`: + +1. Bearer JWT 缺失或非法 +2. Bearer JWT 对应用户不存在 + +仓储读取失败返回 `500 INTERNAL_SERVER_ERROR`。 + +## 6. 当前设备识别规则 + +`isCurrent` 固定按以下规则判断: + +1. 从 refresh cookie 读取当前原始 refresh token +2. 在 Axum 侧计算 `sha256(refresh_token)` +3. 与会话列表中的 `refresh_token_hash` 比较 +4. 命中则 `isCurrent = true` + +说明: + +1. 如果请求没有携带 refresh cookie,本接口仍可返回会话列表 +2. 此时全部会话的 `isCurrent` 都为 `false` + +## 7. 多端标识派生规则 + +### 7.1 后端入库字段 + +登录创建会话时,Axum 必须先解析并写入: + +1. `client_type` +2. `client_runtime` +3. `client_platform` +4. `client_instance_id` +5. `device_fingerprint` +6. `device_display_name` +7. `mini_program_app_id` +8. `mini_program_env` +9. `user_agent` +10. `ip` + +### 7.2 DTO 派生规则 + +会话列表返回时: + +1. `clientType = client_type` +2. `clientRuntime = client_runtime` +3. `clientPlatform = client_platform` +4. `deviceDisplayName = device_display_name` +5. `clientLabel = device_display_name` +6. `miniProgramAppId = mini_program_app_id` +7. `miniProgramEnv = mini_program_env` + +## 8. crate 边界 + +### 8.1 `module-auth` + +负责: + +1. 保存 refresh session 客户端身份快照 +2. 按 `user_id` 返回活跃会话列表 +3. 保持 refresh 轮换后 `session_id` 稳定、客户端身份字段不漂移 + +### 8.2 `api-server` + +负责: + +1. 读取 Bearer JWT 与 refresh cookie +2. 把活跃会话映射成旧接口兼容 DTO +3. 派生 `ipMasked` 与 `isCurrent` + +## 9. 测试策略 + +至少覆盖: + +1. 同一账号在同平台不同浏览器登录后,会话列表能返回两条不同运行时记录 +2. 微信内 H5 登录后,会话列表返回 `wechat_h5 + wechat_embedded_browser` +3. 显式小程序头优先于 `User-Agent` 判断 +4. 请求携带当前 refresh cookie 时,只有当前会话 `isCurrent = true` + +## 10. 完成定义 + +满足以下条件时,本任务视为完成: + +1. Rust 侧已提供 `GET /api/auth/sessions` +2. 会话列表可区分普通浏览器、微信内 H5、小程序来源 +3. 同设备不同浏览器可在会话列表中清晰区分 +4. `clientLabel` 与新增多端字段都已稳定返回 +5. 文档、任务清单与测试已同步更新 diff --git a/docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md b/docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md new file mode 100644 index 00000000..5cc5b6e2 --- /dev/null +++ b/docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md @@ -0,0 +1,309 @@ +# 多端登录会话身份模型设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于补齐当前鉴权体系中“多端登录识别”能力的落地设计,目标是让后端能够稳定标识: + +1. 不同设备上的浏览器登录 +2. 同一设备上的不同浏览器登录 +3. 微信小程序、支付宝小程序、抖音小程序等小程序来源 +4. 微信内 H5 与普通浏览器 H5 的差异 + +同时冻结: + +1. `refresh_session` 需要新增的客户端身份字段 +2. 前端与网关需要补传的上下文 +3. Axum 派生展示名与“当前设备”标识的规则 +4. 与后续 `/api/auth/sessions`、`/api/auth/sessions/:sessionId/revoke` 的契约关系 + +## 2. 当前问题 + +当前项目里“设备类型”只有最小占位语义: + +1. Node `buildAuthRequestContext(...)` 直接写死 `clientType = "browser"` +2. `clientLabel` 只是按 `User-Agent` 粗略显示“移动端浏览器 / 网页端浏览器” +3. `refresh_session` / `user_sessions` 里没有区分运行时、小程序来源、浏览器实例与设备实例的结构化字段 + +这会导致: + +1. 无法区分“同一台电脑上的 Chrome 和 Edge” +2. 无法稳定标记“微信小程序登录”和“微信内 H5 登录” +3. 无法给会话列表生成稳定、可读的端侧标签 + +## 3. 设计目标 + +新的会话客户端身份模型必须满足: + +1. 会话列表能区分“设备”与“客户端运行时” +2. 同设备不同浏览器被视为不同登录端 +3. 小程序来源可稳定区分到平台级别 +4. 前端不需要拿到真实设备名称,也能展示稳定友好标签 +5. 浏览器拿不到真实设备名时,系统仍能自动生成展示名 + +## 4. 基本原则 + +### 4.1 不追求真实设备名 + +浏览器端通常无法拿到: + +1. 系统设置里的设备名称 +2. 手机自定义名称 +3. 电脑主机名 + +因此本设计不依赖真实设备名称,而是生成“会话展示名”。 + +### 4.2 会话识别拆三层 + +当前阶段统一拆为: + +1. `client_type` + 大类,例如浏览器、小程序、桌面客户端 +2. `client_runtime` + 具体运行时,例如 Chrome、Safari、微信小程序 +3. `client_instance_id` + 客户端实例 ID,用于区分“同设备不同浏览器”以及“同浏览器不同安装/清缓存” + +### 4.3 展示名由后端派生 + +`device_display_name` 不直接信任前端自由文本,而由后端基于结构化字段派生,必要时允许前端提供候选值。 + +## 5. 字段模型 + +`refresh_session` 建议新增以下字段: + +| 字段名 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `client_type` | `String` | 是 | 大类,见 6.1 | +| `client_runtime` | `String` | 是 | 运行时,见 6.2 | +| `client_platform` | `String` | 否 | 平台,如 `windows`、`macos`、`ios`、`android` | +| `client_instance_id` | `String` | 否 | 客户端实例 ID,由前端持久化生成 | +| `device_fingerprint` | `String` | 否 | 后端派生指纹,用于聚合同设备近似会话 | +| `device_display_name` | `String` | 是 | 给用户展示的设备/端侧名称 | +| `mini_program_app_id` | `Option` | 否 | 小程序 appid | +| `mini_program_env` | `Option` | 否 | 小程序环境,如 `develop`、`trial`、`release` | +| `user_agent` | `Option` | 否 | 原始 UA | +| `ip` | `Option` | 否 | 登录 IP | + +说明: + +1. `client_type` 和 `client_runtime` 是强约束字段,必须稳定可枚举。 +2. `client_instance_id` 用于区分“同一台设备上的不同浏览器/不同安装实例”。 +3. `device_fingerprint` 不是鉴权凭据,只用于会话展示聚类。 +4. `device_display_name` 是最终展示字段。 + +## 6. 枚举定义 + +### 6.1 `client_type` + +固定枚举: + +1. `web_browser` +2. `wechat_h5` +3. `mini_program` +4. `native_app` +5. `desktop_app` +6. `unknown` + +### 6.2 `client_runtime` + +当前阶段固定允许: + +1. `chrome` +2. `edge` +3. `safari` +4. `firefox` +5. `wechat_embedded_browser` +6. `wechat_mini_program` +7. `alipay_mini_program` +8. `douyin_mini_program` +9. `unknown` + +后续如扩展,不改已有语义,只追加枚举。 + +### 6.3 `client_platform` + +当前阶段固定允许: + +1. `windows` +2. `macos` +3. `linux` +4. `ios` +5. `android` +6. `unknown` + +## 7. 前端采集输入 + +### 7.1 Web 浏览器 + +浏览器侧建议补传以下请求头: + +1. `x-client-type` +2. `x-client-runtime` +3. `x-client-platform` +4. `x-client-instance-id` + +其中: + +1. `x-client-instance-id` + 由前端首次启动时生成并持久化到本地存储 +2. `x-client-runtime` + 可由前端粗判,也允许后端根据 UA 覆盖修正 + +### 7.2 小程序 + +小程序侧额外补传: + +1. `x-client-type=mini_program` +2. `x-client-runtime` + 如 `wechat_mini_program` +3. `x-client-platform` + `ios` / `android` +4. `x-client-instance-id` +5. `x-mini-program-app-id` +6. `x-mini-program-env` + +## 8. 后端判定规则 + +### 8.1 输入优先级 + +Axum 侧按以下顺序解析: + +1. 显式请求头 +2. `User-Agent` 自动识别 +3. 回退默认值 + +### 8.2 默认回退 + +如果没有任何显式端侧头: + +1. `client_type = web_browser` +2. `client_runtime` 由 UA 粗判,判不出则 `unknown` +3. `client_platform` 由 UA 粗判,判不出则 `unknown` + +### 8.3 微信内 H5 + +如果 UA 命中 `MicroMessenger` 且不是小程序显式来源: + +1. `client_type = wechat_h5` +2. `client_runtime = wechat_embedded_browser` + +### 8.4 小程序来源优先于 UA + +如果 `x-client-type=mini_program`: + +1. 不再按普通 UA 逻辑覆盖成浏览器 +2. `client_runtime` 必须优先保留小程序来源值 + +## 9. `client_instance_id` 规则 + +### 9.1 作用 + +它用于区分: + +1. 同设备不同浏览器 +2. 同浏览器不同安装实例 +3. 小程序容器与普通 H5 容器 + +### 9.2 生成策略 + +前端首次启动时生成一个随机字符串并持久化: + +1. Web:`localStorage` / `indexedDB` +2. 小程序:平台侧本地存储 + +说明: + +1. 清浏览器数据后会变化,这是可接受的。 +2. 它不是登录凭据,只是会话识别辅助字段。 + +## 10. `device_fingerprint` 规则 + +后端可基于以下字段做稳定哈希: + +1. `client_type` +2. `client_runtime` +3. `client_platform` +4. `client_instance_id` + +如果 `client_instance_id` 缺失,则回退: + +1. `client_type` +2. `client_runtime` +3. `client_platform` +4. `normalized user_agent` + +说明: + +1. 这个指纹只用于显示和聚类,不作为鉴权依据。 +2. 同一设备上的不同浏览器通常应产生不同指纹。 + +## 11. `device_display_name` 派生规则 + +后端固定按以下优先级派生: + +1. 小程序: + - `微信小程序 / iPhone` + - `微信小程序 / Android` + - `支付宝小程序 / Android` +2. 微信内 H5: + - `微信内网页 / iPhone` + - `微信内网页 / Android` +3. 普通浏览器: + - `Windows / Chrome` + - `macOS / Safari` + - `iPhone / Safari` + - `Android / Chrome` +4. 无法识别时: + - `未知设备` + +## 12. 与会话列表 DTO 的关系 + +当前 `AuthSessionSummary` 至少建议新增: + +1. `clientRuntime` +2. `clientPlatform` +3. `deviceDisplayName` +4. `miniProgramAppId` + +当前已有字段中的: + +1. `clientType` + 保留,但升级为新枚举 +2. `clientLabel` + 后续可由 `deviceDisplayName` 替代,或先兼容保留 + +## 13. 首版 Rust 落地范围 + +当前首版建议先完成: + +1. Axum 从请求头 + UA 解析 `SessionClientContext` +2. `module-auth` 的 `refresh_session` 增加结构化客户端字段 +3. 登录创建 session 时写入这些字段 +4. 单元测试覆盖浏览器、小程序、微信 H5 的识别规则 + +本轮不强制一起完成: + +1. `/api/auth/sessions` +2. 前端全面补传 `x-client-*` 头 +3. 小程序端 SDK 对接 + +## 14. 不允许的设计漂移 + +后续实现时禁止: + +1. 继续把所有终端都写成 `browser` +2. 用 `User-Agent` 文本直接充当最终设备名称 +3. 用 IP 作为设备唯一标识 +4. 把 `client_instance_id` 当成安全凭据 +5. 前端任意上传自由文本设备名并直接持久化 + +## 15. 完成定义 + +满足以下条件时,这条能力设计视为完成: + +1. 会话身份字段已能区分来源、运行时、平台、实例 +2. 同设备不同浏览器与小程序来源都有明确建模 +3. `device_display_name` 已有统一派生规则 +4. 后续 `/api/auth/sessions` 已有稳定可用的数据基础 diff --git a/docs/technical/README.md b/docs/technical/README.md index 38e9c652..a446e588 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,7 +5,9 @@ ## 文档列表 - [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md):`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract,以及用户不存在时的 `401` 语义。 +- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。 - [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。 +- [MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](./MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md):多端登录会话身份模型设计,冻结浏览器、小程序、微信内 H5 的客户端身份字段、请求头约定与展示名派生规则。 - [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` refresh cookie 适配设计,冻结 cookie 配置结构、读取规则与 `api-server` 最小读取链路。 - [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` 首版 JWT 适配设计,冻结 `JwtConfig`、claims 结构、`HS256` 签发/校验、`api-server` Bearer 中间件与内部验收路由边界。 - [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md):面向 Axum、`platform-auth` 与 `SpacetimeDB` 身份透传的 OIDC 风格 JWT claims 设计,冻结 `iss/sub/sid/provider/roles` 等关键字段。 diff --git a/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md index 93eff9af..a65a9465 100644 --- a/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md +++ b/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md @@ -13,6 +13,11 @@ 3. `refresh_session` 与 `user_account.token_version` 的职责切分 4. 会话列表、当前设备识别、轮换与吊销的数据结构 +补充约束: + +1. 多端登录识别字段以 [MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](./MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md) 为准。 +2. 本文负责把这些字段正式并入 `refresh_session` 结构、迁移规则与接口读模型。 + ## 2. 当前基线 当前 Node 后端已经存在一张 `user_sessions` 表,并且 refresh cookie 主链已经完整可用: @@ -44,6 +49,7 @@ 1. refresh session 已经是现有系统的既有真相源。 2. Rust 重写时不需要重新发明另一套“session cache + cookie state”双轨模型。 3. 只需要把当前语义更明确地迁入 SpacetimeDB,并把与 `user_account` 的职责切开。 +4. 旧 Node 基线只有最小 `client_type + user_agent + ip` 粒度,本轮需要升级为结构化客户端身份模型。 ## 3. 边界定义 @@ -129,7 +135,14 @@ | `session_id` | `String` | 是 | 主键,建议继续沿用 `usess_*` 前缀。 | | `user_id` | `String` | 是 | 归属账号 ID,外键指向 `user_account.user_id`。 | | `refresh_token_hash` | `String` | 是 | 当前生效 refresh token 的哈希值。 | -| `client_type` | `String` | 是 | 当前设备类型,当前阶段默认 `browser`。 | +| `client_type` | `String` | 是 | 终端大类,固定枚举见多端会话身份设计。 | +| `client_runtime` | `String` | 是 | 具体运行时,例如 `chrome`、`wechat_mini_program`。 | +| `client_platform` | `String` | 是 | 平台类型,例如 `windows`、`ios`、`android`。 | +| `client_instance_id` | `Option` | 否 | 客户端实例 ID,用于区分同设备不同浏览器或同浏览器不同安装实例。 | +| `device_fingerprint` | `Option` | 否 | 服务端派生的设备聚类指纹,不作为安全凭据。 | +| `device_display_name` | `String` | 是 | 用于会话列表展示的统一端侧名称。 | +| `mini_program_app_id` | `Option` | 否 | 小程序 appid。 | +| `mini_program_env` | `Option` | 否 | 小程序环境,例如 `develop`、`trial`、`release`。 | | `user_agent` | `Option` | 否 | 请求头中的 `User-Agent` 原文。 | | `ip` | `Option` | 否 | 会话创建时采集的客户端 IP。 | | `issued_by_provider` | `String` | 是 | 该会话是由哪种登录链路创建,枚举固定为 `password`、`phone`、`wechat`。 | @@ -145,6 +158,8 @@ 1. 当前阶段时间字段统一继续使用 UTC RFC3339 字符串。 2. `session_id` 在 refresh 轮换时保持不变,不创建新会话行。 3. `issued_by_provider` 不是为了做 provider 身份表,而是为了后续账号安全页和审计展示保留稳定字段。 +4. `device_display_name` 由 Axum 应用层基于结构化字段派生,不直接信任前端自由文本。 +5. `client_type`、`client_runtime`、`client_platform` 的具体枚举口径固定受多端会话身份设计文档约束。 ## 7. 唯一约束与索引 @@ -285,13 +300,21 @@ 1. `session_id` 2. `client_type` -3. `user_agent` -4. `ip` -5. `created_at` -6. `last_seen_at` -7. `expires_at` +3. `client_runtime` +4. `client_platform` +5. `device_display_name` +6. `mini_program_app_id` +7. `mini_program_env` +8. `user_agent` +9. `ip` +10. `created_at` +11. `last_seen_at` +12. `expires_at` -前端 DTO `clientLabel`、`ipMasked`、`isCurrent` 继续在 Axum 侧派生。 +前端 DTO 侧: + +1. `clientLabel` 当前阶段继续兼容保留,但固定与 `deviceDisplayName` 对齐。 +2. `ipMasked`、`isCurrent` 继续在 Axum 侧派生。 ### 10.3 `POST /api/auth/logout` @@ -314,7 +337,7 @@ | `id` | `session_id` | 原样迁移。 | | `user_id` | `user_id` | 原样迁移。 | | `refresh_token_hash` | `refresh_token_hash` | 原样迁移。 | -| `client_type` | `client_type` | 原样迁移。 | +| `client_type` | `client_type` | `browser` 在迁移后统一归一为 `web_browser`。 | | `user_agent` | `user_agent` | 原样迁移。 | | `ip` | `ip` | 原样迁移。 | | `expires_at` | `expires_at` | 原样迁移。 | @@ -330,6 +353,20 @@ 说明:这是保守回填值,后续只影响展示,不影响鉴权正确性 2. `revoked_reason_code` 初次迁移统一回填为 `null` +3. `client_runtime` + 初次迁移按 `User-Agent` 粗判,无法识别时回填 `unknown` +4. `client_platform` + 初次迁移按 `User-Agent` 粗判,无法识别时回填 `unknown` +5. `client_instance_id` + 初次迁移统一回填为 `null` +6. `device_fingerprint` + 初次迁移按 `client_type + client_runtime + client_platform + normalized_user_agent` 派生 +7. `device_display_name` + 初次迁移由后端按多端会话身份规则派生 +8. `mini_program_app_id` + 初次迁移统一回填为 `null` +9. `mini_program_env` + 初次迁移统一回填为 `null` ## 12. reducer / service 落地约束 diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 6ee28cc6..dfa36373 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -84,7 +84,12 @@ export type AuthRefreshResponse = { export type AuthSessionSummary = { sessionId: string; clientType: string; + clientRuntime: string; + clientPlatform: string; clientLabel: string; + deviceDisplayName: string; + miniProgramAppId: string | null; + miniProgramEnv: string | null; userAgent: string | null; ipMasked: string | null; isCurrent: boolean; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index f85dc770..8c02492c 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -16,6 +16,7 @@ use crate::{ require_bearer_auth, }, auth_me::auth_me, + auth_sessions::auth_sessions, error_middleware::normalize_error_response, health::health_check, logout::logout, @@ -56,6 +57,18 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/auth/sessions", + get(auth_sessions) + .route_layer(middleware::from_fn_with_state( + state.clone(), + attach_refresh_session_token, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/auth/refresh", post(refresh_session).route_layer(middleware::from_fn_with_state( @@ -417,6 +430,121 @@ mod tests { assert!(payload["token"].as_str().is_some()); } + #[tokio::test] + async fn auth_sessions_returns_multi_device_session_fields() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let first_login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ) + .header("x-client-instance-id", "chrome-instance-001") + .body(Body::from( + serde_json::json!({ + "username": "guest_sessions_api", + "password": "secret123" + }) + .to_string(), + )) + .expect("first login request should build"), + ) + .await + .expect("first login should succeed"); + let first_cookie = first_login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("first cookie should exist") + .to_string(); + let first_body = first_login_response + .into_body() + .collect() + .await + .expect("first login body should collect") + .to_bytes(); + let first_payload: Value = + serde_json::from_slice(&first_body).expect("first login payload should be json"); + let access_token = first_payload["token"] + .as_str() + .expect("access token should exist") + .to_string(); + + let _second_login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .header("x-client-type", "mini_program") + .header("x-client-runtime", "wechat_mini_program") + .header("x-client-platform", "android") + .header("x-client-instance-id", "mini-instance-001") + .header("x-mini-program-app-id", "wx-session-test") + .header("x-mini-program-env", "release") + .header("user-agent", "Mozilla/5.0 Chrome/123.0 MicroMessenger") + .body(Body::from( + serde_json::json!({ + "username": "guest_sessions_api", + "password": "secret123" + }) + .to_string(), + )) + .expect("second login request should build"), + ) + .await + .expect("second login should succeed"); + + let sessions_response = app + .oneshot( + Request::builder() + .uri("/api/auth/sessions") + .header("authorization", format!("Bearer {access_token}")) + .header("cookie", first_cookie) + .body(Body::empty()) + .expect("sessions request should build"), + ) + .await + .expect("sessions request should succeed"); + + assert_eq!(sessions_response.status(), StatusCode::OK); + let sessions_body = sessions_response + .into_body() + .collect() + .await + .expect("sessions body should collect") + .to_bytes(); + let sessions_payload: Value = + serde_json::from_slice(&sessions_body).expect("sessions payload should be json"); + let sessions = sessions_payload["sessions"] + .as_array() + .expect("sessions should be array"); + + assert_eq!(sessions.len(), 2); + assert!(sessions.iter().any(|session| { + session["clientType"] == Value::String("web_browser".to_string()) + && session["clientRuntime"] == Value::String("chrome".to_string()) + && session["clientPlatform"] == Value::String("windows".to_string()) + && session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string()) + && session["isCurrent"] == Value::Bool(true) + })); + assert!(sessions.iter().any(|session| { + session["clientType"] == Value::String("mini_program".to_string()) + && session["clientRuntime"] == Value::String("wechat_mini_program".to_string()) + && session["miniProgramAppId"] == Value::String("wx-session-test".to_string()) + && session["miniProgramEnv"] == Value::String("release".to_string()) + && session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string()) + && session["isCurrent"] == Value::Bool(false) + })); + } + #[tokio::test] async fn password_entry_reuses_same_user_for_same_credentials() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/auth_session.rs b/server-rs/crates/api-server/src/auth_session.rs index 0bacc6b6..247dff82 100644 --- a/server-rs/crates/api-server/src/auth_session.rs +++ b/server-rs/crates/api-server/src/auth_session.rs @@ -1,7 +1,4 @@ -use axum::http::{ - HeaderMap, HeaderValue, StatusCode, - header::SET_COOKIE, -}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE}; use module_auth::{ AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionError, }; @@ -13,6 +10,7 @@ use platform_auth::{ use time::OffsetDateTime; use crate::{http_error::AppError, state::AppState}; +use crate::{session_client::SessionClientContext}; #[derive(Debug, Clone)] pub struct SignedAuthSession { @@ -23,6 +21,7 @@ pub struct SignedAuthSession { pub fn create_password_auth_session( state: &AppState, user: &AuthUser, + session_client: &SessionClientContext, ) -> Result { let refresh_token = create_refresh_session_token(); let refresh_token_hash = hash_refresh_session_token(&refresh_token); @@ -33,6 +32,7 @@ pub fn create_password_auth_session( user_id: user.id.clone(), refresh_token_hash, issued_by_provider: AuthLoginMethod::Password, + client_info: session_client.to_refresh_session_client_info(), }, OffsetDateTime::now_utc(), ) @@ -95,10 +95,7 @@ pub fn build_clear_refresh_session_cookie_header( }) } -pub fn attach_set_cookie_header( - headers: &mut HeaderMap, - set_cookie: HeaderValue, -) { +pub fn attach_set_cookie_header(headers: &mut HeaderMap, set_cookie: HeaderValue) { headers.insert(SET_COOKIE, set_cookie); } diff --git a/server-rs/crates/api-server/src/auth_sessions.rs b/server-rs/crates/api-server/src/auth_sessions.rs new file mode 100644 index 00000000..a7a94c50 --- /dev/null +++ b/server-rs/crates/api-server/src/auth_sessions.rs @@ -0,0 +1,113 @@ +use axum::{ + Json, + extract::{Extension, State}, + http::StatusCode, +}; +use platform_auth::hash_refresh_session_token; +use serde::Serialize; +use time::OffsetDateTime; + +use crate::{ + api_response::json_success_body, + auth::{AuthenticatedAccessToken, RefreshSessionToken}, + http_error::AppError, + request_context::RequestContext, + session_client::mask_ip, + state::AppState, +}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthSessionsResponse { + pub sessions: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthSessionSummaryPayload { + pub session_id: String, + pub client_type: String, + pub client_runtime: String, + pub client_platform: String, + pub client_label: String, + pub device_display_name: String, + pub mini_program_app_id: Option, + pub mini_program_env: Option, + pub user_agent: Option, + pub ip_masked: Option, + pub is_current: bool, + pub created_at: String, + pub last_seen_at: String, + pub expires_at: String, +} + +pub async fn auth_sessions( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + maybe_refresh_token: Option>, +) -> Result, AppError> { + // 当前设备识别仍然依赖 refresh cookie 命中的原始 token,对旧前端行为保持兼容。 + let user_id = authenticated.claims().user_id().to_string(); + let current_refresh_token_hash = maybe_refresh_token.and_then(|token| { + let token = token.0.token().trim(); + if token.is_empty() { + return None; + } + + Some(hash_refresh_session_token(token)) + }); + + let sessions = state + .refresh_session_service() + .list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc()) + .map_err(map_refresh_session_list_error)?; + + Ok(json_success_body( + Some(&request_context), + AuthSessionsResponse { + sessions: sessions + .sessions + .into_iter() + .map(|session| { + let is_current = current_refresh_token_hash.as_ref().is_some_and(|hash| { + session.refresh_token_hash == *hash + }); + let client_label = session.client_info.device_display_name.clone(); + + AuthSessionSummaryPayload { + session_id: session.session_id, + client_type: session.client_info.client_type, + client_runtime: session.client_info.client_runtime, + client_platform: session.client_info.client_platform, + client_label, + device_display_name: session.client_info.device_display_name, + mini_program_app_id: session.client_info.mini_program_app_id, + mini_program_env: session.client_info.mini_program_env, + user_agent: session.client_info.user_agent, + ip_masked: mask_ip(session.client_info.ip.as_deref()), + is_current, + created_at: session.created_at, + last_seen_at: session.last_seen_at, + expires_at: session.expires_at, + } + }) + .collect(), + }, + )) +} + +fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError { + match error { + module_auth::RefreshSessionError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("当前登录态已失效,请重新登录"), + module_auth::RefreshSessionError::MissingToken + | module_auth::RefreshSessionError::SessionNotFound + | module_auth::RefreshSessionError::SessionExpired => { + AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string()) + } + module_auth::RefreshSessionError::Store(message) => { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message) + } + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 205987f7..07c4d327 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -2,8 +2,9 @@ mod api_response; mod app; mod assets; mod auth; -mod auth_session; mod auth_me; +mod auth_sessions; +mod auth_session; mod config; mod error_middleware; mod health; @@ -13,6 +14,7 @@ mod password_entry; mod refresh_session; mod request_context; mod response_headers; +mod session_client; mod state; use shared_logging::init_tracing; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index e61ac7a3..9cb96323 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -15,6 +15,7 @@ use crate::{ }, http_error::AppError, request_context::RequestContext, + session_client::resolve_session_client_context, state::AppState, }; @@ -47,6 +48,7 @@ pub struct PasswordEntryUserPayload { pub async fn password_entry( State(state): State, Extension(request_context): Extension, + headers: HeaderMap, Json(payload): Json, ) -> Result { let result = state @@ -57,7 +59,8 @@ pub async fn password_entry( }) .await .map_err(map_password_entry_error)?; - let signed_session = create_password_auth_session(&state, &result.user)?; + let session_client = resolve_session_client_context(&headers); + let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/session_client.rs b/server-rs/crates/api-server/src/session_client.rs new file mode 100644 index 00000000..0eeecba1 --- /dev/null +++ b/server-rs/crates/api-server/src/session_client.rs @@ -0,0 +1,433 @@ +use axum::http::HeaderMap; +use module_auth::RefreshSessionClientInfo; +use platform_auth::hash_refresh_session_token; + +const X_CLIENT_TYPE_HEADER: &str = "x-client-type"; +const X_CLIENT_RUNTIME_HEADER: &str = "x-client-runtime"; +const X_CLIENT_PLATFORM_HEADER: &str = "x-client-platform"; +const X_CLIENT_INSTANCE_ID_HEADER: &str = "x-client-instance-id"; +const X_MINI_PROGRAM_APP_ID_HEADER: &str = "x-mini-program-app-id"; +const X_MINI_PROGRAM_ENV_HEADER: &str = "x-mini-program-env"; +const USER_AGENT_HEADER: &str = "user-agent"; +const X_FORWARDED_FOR_HEADER: &str = "x-forwarded-for"; +const X_REAL_IP_HEADER: &str = "x-real-ip"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionClientContext { + // 统一保存登录时采集到的客户端身份快照,后续直接写入 refresh_session。 + pub client_type: String, + pub client_runtime: String, + pub client_platform: String, + pub client_instance_id: Option, + pub device_fingerprint: Option, + pub device_display_name: String, + pub mini_program_app_id: Option, + pub mini_program_env: Option, + pub user_agent: Option, + pub ip: Option, +} + +impl SessionClientContext { + pub fn to_refresh_session_client_info(&self) -> RefreshSessionClientInfo { + RefreshSessionClientInfo { + client_type: self.client_type.clone(), + client_runtime: self.client_runtime.clone(), + client_platform: self.client_platform.clone(), + client_instance_id: self.client_instance_id.clone(), + device_fingerprint: self.device_fingerprint.clone(), + device_display_name: self.device_display_name.clone(), + mini_program_app_id: self.mini_program_app_id.clone(), + mini_program_env: self.mini_program_env.clone(), + user_agent: self.user_agent.clone(), + ip: self.ip.clone(), + } + } +} + +pub fn resolve_session_client_context(headers: &HeaderMap) -> SessionClientContext { + // 显式头优先,UA 自动识别兜底,避免前端没有完全补头时整条登录链路不可用。 + let user_agent = header_value(headers, USER_AGENT_HEADER); + let ua_lower = user_agent + .as_ref() + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + let explicit_client_type = normalize_client_type(header_value(headers, X_CLIENT_TYPE_HEADER)); + let explicit_client_runtime = + normalize_runtime(header_value(headers, X_CLIENT_RUNTIME_HEADER), &ua_lower); + let explicit_client_platform = + normalize_platform(header_value(headers, X_CLIENT_PLATFORM_HEADER), &ua_lower); + let client_instance_id = normalize_optional_string(header_value( + headers, + X_CLIENT_INSTANCE_ID_HEADER, + )); + let mini_program_app_id = + normalize_optional_string(header_value(headers, X_MINI_PROGRAM_APP_ID_HEADER)); + let mini_program_env = + normalize_optional_string(header_value(headers, X_MINI_PROGRAM_ENV_HEADER)); + + let inferred_client_type = infer_client_type(explicit_client_type.as_deref(), &ua_lower); + let inferred_runtime = + infer_client_runtime(explicit_client_runtime.as_deref(), &inferred_client_type, &ua_lower); + let inferred_platform = infer_client_platform(explicit_client_platform.as_deref(), &ua_lower); + let ip = resolve_ip(headers); + let device_display_name = + build_device_display_name(&inferred_client_type, &inferred_runtime, &inferred_platform); + let device_fingerprint = + build_device_fingerprint(&inferred_client_type, &inferred_runtime, &inferred_platform, client_instance_id.as_deref(), user_agent.as_deref()); + + SessionClientContext { + client_type: inferred_client_type, + client_runtime: inferred_runtime, + client_platform: inferred_platform, + client_instance_id, + device_fingerprint, + device_display_name, + mini_program_app_id, + mini_program_env, + user_agent: normalize_optional_string(user_agent), + ip, + } +} + +fn header_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|raw| { + let normalized = raw.trim().to_string(); + if normalized.is_empty() { + return None; + } + + Some(normalized) + }) +} + +fn normalize_client_type(value: Option) -> Option { + value.and_then(|raw| { + let normalized = raw.trim().to_ascii_lowercase(); + match normalized.as_str() { + "web_browser" | "wechat_h5" | "mini_program" | "native_app" | "desktop_app" + | "unknown" => Some(normalized), + _ => None, + } + }) +} + +fn normalize_runtime(value: Option, ua_lower: &str) -> Option { + value.and_then(|raw| { + let normalized = raw.trim().to_ascii_lowercase(); + match normalized.as_str() { + "chrome" + | "edge" + | "safari" + | "firefox" + | "wechat_embedded_browser" + | "wechat_mini_program" + | "alipay_mini_program" + | "douyin_mini_program" + | "unknown" => Some(normalized), + _ => infer_runtime_from_user_agent(ua_lower), + } + }) +} + +fn normalize_platform(value: Option, ua_lower: &str) -> Option { + value.and_then(|raw| { + let normalized = raw.trim().to_ascii_lowercase(); + match normalized.as_str() { + "windows" | "macos" | "linux" | "ios" | "android" | "unknown" => Some(normalized), + _ => infer_platform_from_user_agent(ua_lower), + } + }) +} + +fn infer_client_type(explicit_type: Option<&str>, ua_lower: &str) -> String { + if let Some(client_type) = explicit_type { + return client_type.to_string(); + } + + if ua_lower.contains("micromessenger") { + return "wechat_h5".to_string(); + } + + "web_browser".to_string() +} + +fn infer_client_runtime(explicit_runtime: Option<&str>, client_type: &str, ua_lower: &str) -> String { + if client_type == "mini_program" { + if let Some(runtime) = explicit_runtime { + return runtime.to_string(); + } + if ua_lower.contains("alipayclient") { + return "alipay_mini_program".to_string(); + } + if ua_lower.contains("toutiaomicroapp") || ua_lower.contains("douyin") { + return "douyin_mini_program".to_string(); + } + return "wechat_mini_program".to_string(); + } + + if client_type == "wechat_h5" { + return "wechat_embedded_browser".to_string(); + } + + explicit_runtime + .map(ToOwned::to_owned) + .or_else(|| infer_runtime_from_user_agent(ua_lower)) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn infer_client_platform(explicit_platform: Option<&str>, ua_lower: &str) -> String { + explicit_platform + .map(ToOwned::to_owned) + .or_else(|| infer_platform_from_user_agent(ua_lower)) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn infer_runtime_from_user_agent(ua_lower: &str) -> Option { + if ua_lower.contains("edg/") { + return Some("edge".to_string()); + } + if ua_lower.contains("firefox/") { + return Some("firefox".to_string()); + } + if ua_lower.contains("chrome/") || ua_lower.contains("crios/") { + return Some("chrome".to_string()); + } + if ua_lower.contains("safari/") && !ua_lower.contains("chrome/") && !ua_lower.contains("crios/") { + return Some("safari".to_string()); + } + + None +} + +fn infer_platform_from_user_agent(ua_lower: &str) -> Option { + if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ios") { + return Some("ios".to_string()); + } + if ua_lower.contains("android") { + return Some("android".to_string()); + } + if ua_lower.contains("windows") { + return Some("windows".to_string()); + } + if ua_lower.contains("mac os") || ua_lower.contains("macintosh") { + return Some("macos".to_string()); + } + if ua_lower.contains("linux") { + return Some("linux".to_string()); + } + + None +} + +fn build_device_display_name(client_type: &str, client_runtime: &str, client_platform: &str) -> String { + // 展示名固定由后端派生,避免前端上传自由文本导致同类设备标签漂移。 + if client_type == "mini_program" { + return format!( + "{} / {}", + map_runtime_display(client_runtime), + map_platform_display(client_platform) + ); + } + if client_type == "wechat_h5" { + return format!("微信内网页 / {}", map_platform_display(client_platform)); + } + if client_type == "unknown" { + return "未知设备".to_string(); + } + + format!( + "{} / {}", + map_platform_display(client_platform), + map_runtime_display(client_runtime) + ) +} + +fn map_runtime_display(runtime: &str) -> &'static str { + match runtime { + "chrome" => "Chrome", + "edge" => "Edge", + "safari" => "Safari", + "firefox" => "Firefox", + "wechat_embedded_browser" => "微信内网页", + "wechat_mini_program" => "微信小程序", + "alipay_mini_program" => "支付宝小程序", + "douyin_mini_program" => "抖音小程序", + _ => "未知客户端", + } +} + +fn map_platform_display(platform: &str) -> &'static str { + match platform { + "windows" => "Windows", + "macos" => "macOS", + "linux" => "Linux", + "ios" => "iPhone", + "android" => "Android", + _ => "未知设备", + } +} + +fn build_device_fingerprint( + client_type: &str, + client_runtime: &str, + client_platform: &str, + client_instance_id: Option<&str>, + user_agent: Option<&str>, +) -> Option { + // 这里的指纹只用于会话聚类与展示,不参与任何鉴权决策。 + let seed = if let Some(instance_id) = client_instance_id { + format!("{client_type}|{client_runtime}|{client_platform}|{instance_id}") + } else if let Some(user_agent) = user_agent { + format!( + "{client_type}|{client_runtime}|{client_platform}|{}", + normalize_user_agent(user_agent) + ) + } else { + return None; + }; + + Some(hash_refresh_session_token(&seed)) +} + +fn normalize_user_agent(user_agent: &str) -> String { + user_agent + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + +fn resolve_ip(headers: &HeaderMap) -> Option { + if let Some(forwarded) = header_value(headers, X_FORWARDED_FOR_HEADER) { + let ip = forwarded + .split(',') + .map(str::trim) + .find(|value| !value.is_empty()) + .map(ToOwned::to_owned); + if ip.is_some() { + return ip; + } + } + + normalize_optional_string(header_value(headers, X_REAL_IP_HEADER)) +} + +pub fn mask_ip(ip: Option<&str>) -> Option { + // 会话列表只返回脱敏后的 IP,避免把完整地址直接暴露给前端。 + let ip = ip?.trim(); + if ip.is_empty() { + return None; + } + + if ip.contains(':') { + let parts = ip.split(':').filter(|part| !part.is_empty()).collect::>(); + if parts.len() <= 2 { + return Some(ip.to_string()); + } + return Some(format!("{}:{}::*", parts[0], parts[1])); + } + + let parts = ip.split('.').collect::>(); + if parts.len() != 4 { + return Some(ip.to_string()); + } + + Some(format!("{}.{}.*.*", parts[0], parts[1])) +} + +#[cfg(test)] +mod tests { + use axum::http::{HeaderMap, HeaderValue}; + + use super::{mask_ip, resolve_session_client_context}; + + #[test] + fn resolve_session_client_context_detects_wechat_h5_from_user_agent() { + let mut headers = HeaderMap::new(); + headers.insert( + "user-agent", + HeaderValue::from_static( + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit Safari MicroMessenger", + ), + ); + + let context = resolve_session_client_context(&headers); + + assert_eq!(context.client_type, "wechat_h5"); + assert_eq!(context.client_runtime, "wechat_embedded_browser"); + assert_eq!(context.client_platform, "ios"); + assert_eq!(context.device_display_name, "微信内网页 / iPhone"); + } + + #[test] + fn resolve_session_client_context_prefers_explicit_mini_program_headers() { + let mut headers = HeaderMap::new(); + headers.insert("x-client-type", HeaderValue::from_static("mini_program")); + headers.insert( + "x-client-runtime", + HeaderValue::from_static("wechat_mini_program"), + ); + headers.insert("x-client-platform", HeaderValue::from_static("android")); + headers.insert( + "x-client-instance-id", + HeaderValue::from_static("mini-instance-001"), + ); + headers.insert( + "x-mini-program-app-id", + HeaderValue::from_static("wx1234567890"), + ); + headers.insert("x-mini-program-env", HeaderValue::from_static("release")); + headers.insert( + "user-agent", + HeaderValue::from_static("Mozilla/5.0 Chrome/123.0 MicroMessenger"), + ); + + let context = resolve_session_client_context(&headers); + + assert_eq!(context.client_type, "mini_program"); + assert_eq!(context.client_runtime, "wechat_mini_program"); + assert_eq!(context.client_platform, "android"); + assert_eq!(context.mini_program_app_id.as_deref(), Some("wx1234567890")); + assert_eq!(context.mini_program_env.as_deref(), Some("release")); + assert_eq!(context.device_display_name, "微信小程序 / Android"); + assert!(context.device_fingerprint.is_some()); + } + + #[test] + fn resolve_session_client_context_distinguishes_web_browser_runtime() { + let mut headers = HeaderMap::new(); + headers.insert( + "user-agent", + HeaderValue::from_static( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ), + ); + headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.11")); + + let context = resolve_session_client_context(&headers); + + assert_eq!(context.client_type, "web_browser"); + assert_eq!(context.client_runtime, "chrome"); + assert_eq!(context.client_platform, "windows"); + assert_eq!(context.ip.as_deref(), Some("203.0.113.11")); + assert_eq!(context.device_display_name, "Windows / Chrome"); + } + + #[test] + fn mask_ip_returns_masked_ipv4_and_ipv6() { + assert_eq!(mask_ip(Some("203.0.113.11")).as_deref(), Some("203.0.*.*")); + assert_eq!( + mask_ip(Some("2408:8000:abcd:1234::1")).as_deref(), + Some("2408:8000::*") + ); + } +} diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 39d4c467..4c50ab69 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -61,6 +61,21 @@ pub struct CreateRefreshSessionInput { pub user_id: String, pub refresh_token_hash: String, pub issued_by_provider: AuthLoginMethod, + pub client_info: RefreshSessionClientInfo, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RefreshSessionClientInfo { + pub client_type: String, + pub client_runtime: String, + pub client_platform: String, + pub client_instance_id: Option, + pub device_fingerprint: Option, + pub device_display_name: String, + pub mini_program_app_id: Option, + pub mini_program_env: Option, + pub user_agent: Option, + pub ip: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -69,6 +84,7 @@ pub struct RefreshSessionRecord { pub user_id: String, pub refresh_token_hash: String, pub issued_by_provider: AuthLoginMethod, + pub client_info: RefreshSessionClientInfo, pub expires_at: String, pub revoked_at: Option, pub created_at: String, @@ -93,6 +109,11 @@ pub struct RotateRefreshSessionResult { pub user: AuthUser, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ListActiveRefreshSessionsResult { + pub sessions: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogoutCurrentSessionInput { pub user_id: String, @@ -253,10 +274,14 @@ impl RefreshSessionService { let session_id = format!("usess_{}", Uuid::new_v4().simple()); let expires_at = now .checked_add(Duration::days(i64::from(self.refresh_session_ttl_days))) - .ok_or_else(|| RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()))?; - let now_iso = now.format(&time::format_description::well_known::Rfc3339).map_err( - |error| RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")), - )?; + .ok_or_else(|| { + RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()) + })?; + let now_iso = now + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| { + RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")) + })?; let expires_at_iso = expires_at .format(&time::format_description::well_known::Rfc3339) .map_err(|error| { @@ -267,6 +292,7 @@ impl RefreshSessionService { user_id: input.user_id, refresh_token_hash: input.refresh_token_hash, issued_by_provider: input.issued_by_provider, + client_info: input.client_info, expires_at: expires_at_iso, revoked_at: None, created_at: now_iso.clone(), @@ -302,7 +328,9 @@ impl RefreshSessionService { &session.session.expires_at, &time::format_description::well_known::Rfc3339, ) - .map_err(|error| RefreshSessionError::Store(format!("refresh session 过期时间解析失败:{error}")))?; + .map_err(|error| { + RefreshSessionError::Store(format!("refresh session 过期时间解析失败:{error}")) + })?; if expires_at <= now { return Err(RefreshSessionError::SessionExpired); } @@ -315,10 +343,14 @@ impl RefreshSessionService { let next_expires_at = now .checked_add(Duration::days(i64::from(self.refresh_session_ttl_days))) - .ok_or_else(|| RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()))?; - let now_iso = now.format(&time::format_description::well_known::Rfc3339).map_err( - |error| RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")), - )?; + .ok_or_else(|| { + RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()) + })?; + let now_iso = now + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| { + RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")) + })?; let next_expires_at_iso = next_expires_at .format(&time::format_description::well_known::Rfc3339) .map_err(|error| { @@ -339,6 +371,20 @@ impl RefreshSessionService { user: user.user, }) } + + pub fn list_active_sessions_by_user( + &self, + user_id: &str, + now: OffsetDateTime, + ) -> Result { + self.store + .find_by_user_id(user_id) + .map_err(map_password_store_error)? + .ok_or(RefreshSessionError::UserNotFound)?; + + let sessions = self.store.list_active_sessions_by_user(user_id, now)?; + Ok(ListActiveRefreshSessionsResult { sessions }) + } } impl AuthUserService { @@ -346,10 +392,7 @@ impl AuthUserService { Self { store } } - pub fn get_user_by_id( - &self, - user_id: &str, - ) -> Result, LogoutError> { + pub fn get_user_by_id(&self, user_id: &str) -> Result, LogoutError> { self.store .find_by_user_id(user_id) .map(|maybe_user| maybe_user.map(|stored| stored.user)) @@ -461,10 +504,7 @@ impl InMemoryAuthStore { Ok(user) } - fn insert_session( - &self, - session: RefreshSessionRecord, - ) -> Result<(), RefreshSessionError> { + fn insert_session(&self, session: RefreshSessionRecord) -> Result<(), RefreshSessionError> { let mut state = self .inner .lock() @@ -483,10 +523,9 @@ impl InMemoryAuthStore { session.refresh_token_hash.clone(), session.session_id.clone(), ); - state.sessions_by_id.insert( - session.session_id.clone(), - StoredRefreshSession { session }, - ); + state + .sessions_by_id + .insert(session.session_id.clone(), StoredRefreshSession { session }); Ok(()) } @@ -499,13 +538,60 @@ impl InMemoryAuthStore { .inner .lock() .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; - let Some(session_id) = state.session_id_by_refresh_token_hash.get(refresh_token_hash) else { + let Some(session_id) = state + .session_id_by_refresh_token_hash + .get(refresh_token_hash) + else { return Ok(None); }; Ok(state.sessions_by_id.get(session_id).cloned()) } + fn list_active_sessions_by_user( + &self, + user_id: &str, + now: OffsetDateTime, + ) -> Result, RefreshSessionError> { + let state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let now_unix = now.unix_timestamp(); + + let mut sessions = state + .sessions_by_id + .values() + .filter_map(|stored| { + if stored.session.user_id != user_id { + return None; + } + if stored.session.revoked_at.is_some() { + return None; + } + let expires_at = OffsetDateTime::parse( + &stored.session.expires_at, + &time::format_description::well_known::Rfc3339, + ) + .ok()?; + if expires_at.unix_timestamp() <= now_unix { + return None; + } + + Some(stored.session.clone()) + }) + .collect::>(); + + sessions.sort_by(|left, right| { + right + .last_seen_at + .cmp(&left.last_seen_at) + .then_with(|| right.created_at.cmp(&left.created_at)) + }); + + Ok(sessions) + } + fn rotate_session( &self, session_id: &str, @@ -552,9 +638,10 @@ impl InMemoryAuthStore { stored.session.updated_at = updated_at; stored.session.last_seen_at = last_seen_at; let updated_session = stored.clone(); - state - .session_id_by_refresh_token_hash - .insert(next_refresh_token_hash, updated_session.session.session_id.clone()); + state.session_id_by_refresh_token_hash.insert( + next_refresh_token_hash, + updated_session.session.session_id.clone(), + ); Ok(updated_session) } @@ -583,7 +670,9 @@ impl InMemoryAuthStore { } let now_iso = now .format(&time::format_description::well_known::Rfc3339) - .map_err(|error| RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")))?; + .map_err(|error| { + RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")) + })?; stored.session.revoked_at = Some(now_iso.clone()); stored.session.updated_at = now_iso; @@ -753,6 +842,21 @@ mod tests { AuthUserService::new(store) } + fn build_client_info() -> RefreshSessionClientInfo { + RefreshSessionClientInfo { + client_type: "web_browser".to_string(), + client_runtime: "chrome".to_string(), + client_platform: "windows".to_string(), + client_instance_id: Some("client-instance-001".to_string()), + device_fingerprint: Some("device-fingerprint-001".to_string()), + device_display_name: "Windows / Chrome".to_string(), + mini_program_app_id: None, + mini_program_env: None, + user_agent: Some("Mozilla/5.0".to_string()), + ip: Some("203.0.113.10".to_string()), + } + } + #[tokio::test] async fn first_password_entry_creates_user() { let service = build_password_service(build_store()); @@ -856,6 +960,7 @@ mod tests { user_id: user.id.clone(), refresh_token_hash: first_token_hash.clone(), issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), }, now, ) @@ -918,6 +1023,7 @@ mod tests { user_id: user.id.clone(), refresh_token_hash: refresh_token_hash.clone(), issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), }, OffsetDateTime::now_utc(), ) @@ -946,4 +1052,82 @@ mod tests { .expect_err("revoked session should fail"); assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); } + + #[tokio::test] + async fn list_active_sessions_by_user_filters_revoked_and_expired_sessions() { + let store = build_store(); + let password_service = build_password_service(store.clone()); + let refresh_service = build_refresh_service(store.clone()); + let user = password_service + .execute(PasswordEntryInput { + username: "guest_sessions".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed") + .user; + let now = OffsetDateTime::now_utc(); + + let active_session = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("sessions-active"), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("active session should create"); + + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("sessions-revoked"), + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "edge".to_string(), + device_display_name: "Windows / Edge".to_string(), + ..build_client_info() + }, + }, + now - Duration::minutes(5), + ) + .expect("revoked session should create"); + store + .revoke_session_by_refresh_token_hash( + &hash_refresh_session_token("sessions-revoked"), + now - Duration::minutes(1), + ) + .expect("revoked session should revoke"); + + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("sessions-expired"), + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "firefox".to_string(), + device_display_name: "Windows / Firefox".to_string(), + ..build_client_info() + }, + }, + now - Duration::days(40), + ) + .expect("expired session should create"); + + let listed = refresh_service + .list_active_sessions_by_user(&user.id, now) + .expect("sessions should list"); + + assert_eq!(listed.sessions.len(), 1); + assert_eq!(listed.sessions[0].session_id, active_session.session.session_id); + assert_eq!(listed.sessions[0].client_info.client_runtime, "chrome"); + assert_eq!( + listed.sessions[0].client_info.device_display_name, + "Windows / Chrome" + ); + } }