feat: add multi-device session identity

This commit is contained in:
2026-04-21 16:25:45 +08:00
parent a83c64133d
commit fcaf7bdb38
13 changed files with 1445 additions and 45 deletions

View File

@@ -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`

View File

@@ -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. 文档、任务清单与测试已同步更新

View File

@@ -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<String>` | 否 | 小程序 appid |
| `mini_program_env` | `Option<String>` | 否 | 小程序环境,如 `develop``trial``release` |
| `user_agent` | `Option<String>` | 否 | 原始 UA |
| `ip` | `Option<String>` | 否 | 登录 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` 已有稳定可用的数据基础

View File

@@ -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` 等关键字段。

View File

@@ -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<String>` | 否 | 客户端实例 ID用于区分同设备不同浏览器或同浏览器不同安装实例。 |
| `device_fingerprint` | `Option<String>` | 否 | 服务端派生的设备聚类指纹,不作为安全凭据。 |
| `device_display_name` | `String` | 是 | 用于会话列表展示的统一端侧名称。 |
| `mini_program_app_id` | `Option<String>` | 否 | 小程序 appid。 |
| `mini_program_env` | `Option<String>` | 否 | 小程序环境,例如 `develop``trial``release`。 |
| `user_agent` | `Option<String>` | 否 | 请求头中的 `User-Agent` 原文。 |
| `ip` | `Option<String>` | 否 | 会话创建时采集的客户端 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 落地约束

View File

@@ -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;

View File

@@ -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"));

View File

@@ -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<SignedAuthSession, AppError> {
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);
}

View File

@@ -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<AuthSessionSummaryPayload>,
}
#[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<String>,
pub mini_program_env: Option<String>,
pub user_agent: Option<String>,
pub ip_masked: Option<String>,
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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
) -> Result<Json<serde_json::Value>, 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)
}
}
}

View File

@@ -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;

View File

@@ -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<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
Json(payload): Json<PasswordEntryRequest>,
) -> Result<impl IntoResponse, AppError> {
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(

View File

@@ -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<String>,
pub device_fingerprint: Option<String>,
pub device_display_name: String,
pub mini_program_app_id: Option<String>,
pub mini_program_env: Option<String>,
pub user_agent: Option<String>,
pub ip: Option<String>,
}
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<String> {
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<String>) -> Option<String> {
value.and_then(|raw| {
let normalized = raw.trim().to_string();
if normalized.is_empty() {
return None;
}
Some(normalized)
})
}
fn normalize_client_type(value: Option<String>) -> Option<String> {
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<String>, ua_lower: &str) -> Option<String> {
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<String>, ua_lower: &str) -> Option<String> {
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<String> {
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<String> {
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<String> {
// 这里的指纹只用于会话聚类与展示,不参与任何鉴权决策。
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::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn resolve_ip(headers: &HeaderMap) -> Option<String> {
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<String> {
// 会话列表只返回脱敏后的 IP避免把完整地址直接暴露给前端。
let ip = ip?.trim();
if ip.is_empty() {
return None;
}
if ip.contains(':') {
let parts = ip.split(':').filter(|part| !part.is_empty()).collect::<Vec<_>>();
if parts.len() <= 2 {
return Some(ip.to_string());
}
return Some(format!("{}:{}::*", parts[0], parts[1]));
}
let parts = ip.split('.').collect::<Vec<_>>();
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::*")
);
}
}

View File

@@ -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<String>,
pub device_fingerprint: Option<String>,
pub device_display_name: String,
pub mini_program_app_id: Option<String>,
pub mini_program_env: Option<String>,
pub user_agent: Option<String>,
pub ip: Option<String>,
}
#[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<String>,
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<RefreshSessionRecord>,
}
#[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<ListActiveRefreshSessionsResult, RefreshSessionError> {
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<Option<AuthUser>, LogoutError> {
pub fn get_user_by_id(&self, user_id: &str) -> Result<Option<AuthUser>, 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<Vec<RefreshSessionRecord>, 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::<Vec<_>>();
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"
);
}
}