fix(auth): tighten refresh session revocation
This commit is contained in:
@@ -16,6 +16,22 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-13 修改密码后全设备强制下线
|
||||
|
||||
- 背景:修改密码原本只递增 `token_version`,旧 access token 会失效,但旧 refresh cookie 仍可通过 `/api/auth/refresh` 重新签发新 token,不符合“改密后全设备强制下线”的账号安全预期。
|
||||
- 决策:`POST /api/auth/password/change` 成功后必须在同一认证真相更新中撤销该用户全部 active `refresh_session`,继续递增 `token_version`,响应清除当前 refresh cookie;前端 `changePassword` 成功后清空本地 access token 并回到未登录态。用户需要使用新密码重新登录。
|
||||
- 影响范围:`module-auth` 修改密码用例、`api-server` password management route、`AuthGate`、`authService`、密码登录/重置技术文档。
|
||||
- 验证方式:执行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml password_change_allows_login_with_new_password_only -- --nocapture`、`npm run test -- AuthGate.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`、`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`。
|
||||
|
||||
## 2026-05-13 refresh_session 会话组后端聚合与远端踢下线
|
||||
|
||||
- 背景:账号安全页中同设备同 IP 的多条 active `refresh_session` 会重复展示;退出登录没有稳定撤销当前 refresh session;前端“踢下线”只做本地状态变化,未真正让远端设备失效。
|
||||
- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions,响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session,使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session,并继续递增 `token_version`。
|
||||
- 影响范围:`module-auth` refresh session service、`api-server` auth middleware/logout/sessions route、`shared-contracts`/TS auth contract、`AuthGate`、`AccountModal`、认证会话技术文档和路由/埋点索引。
|
||||
- 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture`、`npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`,并用 `npm run api-server` 检查 `/healthz`。
|
||||
- 关联文档:`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`、`docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`、`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`。
|
||||
|
||||
## 2026-05-12 抓大鹅入口素材风格改为 2D 常见素材风格
|
||||
|
||||
- 背景:抓大鹅草稿素材生成已经收敛为多视角 2D 图片素材,但入口页和旧参考图仍沿用黏土、低多边形、塑料、木雕、体素、金属等偏 3D 素材语言,容易让后续生成链路和用户预期继续漂移。
|
||||
@@ -55,7 +71,6 @@
|
||||
- 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。
|
||||
- 验证方式:执行 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、触碰文件 ESLint、`npm run check:encoding`。
|
||||
- 关联文档:`docs/prd/PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md`。
|
||||
|
||||
## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权
|
||||
|
||||
- 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`,H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
2. 当前设备识别方式与 `isCurrent` 语义
|
||||
3. 多端登录识别字段如何从 `refresh_session` 派生到 DTO
|
||||
4. Rust 首版在 Axum + 进程内 `module-auth` 下的最小实现边界
|
||||
5. `2026-05-13` 会话组合并展示与远端踢下线闭环修复口径
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
@@ -46,11 +47,16 @@
|
||||
3. 登录创建 session 时落库结构化客户端身份字段
|
||||
4. 会话列表返回多端识别所需字段,并兼容旧 `clientLabel`
|
||||
|
||||
本阶段明确不包含:
|
||||
`2026-05-13` 起,本接口同时承担账号安全页的会话组读模型:
|
||||
|
||||
1. `/api/auth/sessions/:sessionId/revoke`
|
||||
2. 前端完整消费全部新增字段
|
||||
3. SpacetimeDB reducer / view 正式读表
|
||||
1. 后端按“同设备 + 同 IP”聚合活跃 `refresh_session`
|
||||
2. 前端只消费后端聚合结果,不自行推断合并
|
||||
3. `POST /api/auth/sessions/{sessionId}/revoke` 已纳入 Rust 实现,用于踢下线非当前会话
|
||||
|
||||
本阶段仍明确不包含:
|
||||
|
||||
1. SpacetimeDB reducer / view 正式读表
|
||||
2. 登录方式、refresh token 轮换策略或账号安全页整体重设计
|
||||
|
||||
## 5. 请求与响应 contract
|
||||
|
||||
@@ -70,6 +76,8 @@
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": "usess_xxx",
|
||||
"sessionIds": ["usess_xxx", "usess_yyy"],
|
||||
"sessionCount": 2,
|
||||
"clientType": "web_browser",
|
||||
"clientRuntime": "chrome",
|
||||
"clientPlatform": "windows",
|
||||
@@ -90,9 +98,12 @@
|
||||
|
||||
字段说明:
|
||||
|
||||
1. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致
|
||||
2. `clientRuntime`、`clientPlatform`、`deviceDisplayName` 是多端识别首版最小新增字段
|
||||
3. 小程序来源额外暴露 `miniProgramAppId`、`miniProgramEnv`
|
||||
1. `sessionId` 是聚合组代表会话 ID;若组内包含当前 `sid`,代表 ID 必须使用当前会话 ID
|
||||
2. `sessionIds` 是该聚合组内全部活跃 session ID,前端批量踢下线时逐个调用 revoke
|
||||
3. `sessionCount` 是聚合组内 session 数量
|
||||
4. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致
|
||||
5. `clientRuntime`、`clientPlatform`、`deviceDisplayName` 是多端识别首版最小新增字段
|
||||
6. 小程序来源额外暴露 `miniProgramAppId`、`miniProgramEnv`
|
||||
|
||||
### 5.3 失败响应
|
||||
|
||||
@@ -110,12 +121,25 @@
|
||||
1. 从 refresh cookie 读取当前原始 refresh token
|
||||
2. 在 Axum 侧计算 `sha256(refresh_token)`
|
||||
3. 与会话列表中的 `refresh_token_hash` 比较
|
||||
4. 命中则 `isCurrent = true`
|
||||
4. 同时读取 Bearer access token claims 中的 `sid`
|
||||
5. 聚合组内任意 session 命中当前 refresh hash 或当前 `sid`,则整组 `isCurrent = true`
|
||||
|
||||
说明:
|
||||
|
||||
1. 如果请求没有携带 refresh cookie,本接口仍可返回会话列表
|
||||
2. 此时全部会话的 `isCurrent` 都为 `false`
|
||||
2. 此时仍可通过 Bearer `sid` 标记当前组
|
||||
3. 当前组不允许在前端显示“踢下线”,当前设备退出必须走 `/api/auth/logout`
|
||||
|
||||
## 6.1 会话组合并规则
|
||||
|
||||
同设备同 IP 的 active refresh sessions 在后端合并为一条 DTO:
|
||||
|
||||
1. 优先使用 `device_fingerprint + ip` 作为聚合 key
|
||||
2. 无 `device_fingerprint` 时退化为 `client_type + client_runtime + client_platform + device_display_name + user_agent + ip`
|
||||
3. `createdAt` 取组内最早 `created_at`
|
||||
4. `lastSeenAt` 取组内最新 `last_seen_at`
|
||||
5. `expiresAt` 取组内最新 `expires_at`
|
||||
6. `ipMasked` 仍只返回脱敏 IP
|
||||
|
||||
## 7. 多端标识派生规则
|
||||
|
||||
@@ -161,8 +185,21 @@
|
||||
负责:
|
||||
|
||||
1. 读取 Bearer JWT 与 refresh cookie
|
||||
2. 把活跃会话映射成旧接口兼容 DTO
|
||||
3. 派生 `ipMasked` 与 `isCurrent`
|
||||
2. 按同设备同 IP 聚合活跃会话
|
||||
3. 把活跃会话组映射成旧接口兼容 DTO
|
||||
4. 派生 `ipMasked` 与 `isCurrent`
|
||||
5. 暴露 `POST /api/auth/sessions/{sessionId}/revoke`
|
||||
|
||||
## 8.3 指定会话吊销接口
|
||||
|
||||
`POST /api/auth/sessions/{sessionId}/revoke` 固定规则:
|
||||
|
||||
1. Bearer JWT 必填
|
||||
2. 仅允许吊销当前用户自己的非当前会话
|
||||
3. 当前会话自吊销返回业务错误,提示使用退出登录
|
||||
4. 只撤销目标 `refresh_session`,不递增 `token_version`
|
||||
5. 撤销后同步 auth store 到 SpacetimeDB
|
||||
6. 认证中间件会校验 access token `sid` 对应 active `refresh_session`,因此被踢设备已有 access token 会立即失效
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
@@ -172,6 +209,9 @@
|
||||
2. 微信内 H5 登录后,会话列表返回 `wechat_h5 + wechat_embedded_browser`
|
||||
3. 显式小程序头优先于 `User-Agent` 判断
|
||||
4. 请求携带当前 refresh cookie 时,只有当前会话 `isCurrent = true`
|
||||
5. 同设备同 IP 会话会合并,并返回 `sessionIds/sessionCount`
|
||||
6. 合并组包含当前 `sid` 或当前 refresh hash 时,整组 `isCurrent = true`
|
||||
7. 指定远端会话吊销后,被踢设备 access token 立即无法通过认证
|
||||
|
||||
## 10. 完成定义
|
||||
|
||||
@@ -181,4 +221,6 @@
|
||||
2. 会话列表可区分普通浏览器、微信内 H5、小程序来源
|
||||
3. 同设备不同浏览器可在会话列表中清晰区分
|
||||
4. `clientLabel` 与新增多端字段都已稳定返回
|
||||
5. 文档、任务清单与测试已同步更新
|
||||
5. 同设备同 IP 的重复 active refresh sessions 已合并展示
|
||||
6. 非当前会话可通过真实 revoke 接口踢下线
|
||||
7. 文档、任务清单与测试已同步更新
|
||||
|
||||
@@ -94,6 +94,7 @@ API Server 新增统一 helper:
|
||||
| `auth_phone_login_success` | `POST /api/auth/phone/login` |
|
||||
| `auth_me_view` | `GET /api/auth/me` |
|
||||
| `auth_sessions_view` | `GET /api/auth/sessions` |
|
||||
| `auth_revoke_session` | `POST /api/auth/sessions/{session_id}/revoke` |
|
||||
| `auth_refresh_success` | `POST /api/auth/refresh` |
|
||||
| `auth_logout` | `POST /api/auth/logout` |
|
||||
| `auth_logout_all` | `POST /api/auth/logout-all` |
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
2. 请求字段:`currentPassword`、`newPassword`。
|
||||
3. 若账号未设置过密码,允许 `currentPassword` 为空并设置首个密码。
|
||||
4. 若账号已有密码,必须校验 `currentPassword` 后才能写入 `newPassword`。
|
||||
5. 修改成功后递增用户 `token_version`,使旧 access token 失效;前端沿用当前 refresh 会话刷新登录态。
|
||||
5. 修改成功后递增用户 `token_version`,使旧 access token 失效。
|
||||
6. `2026-05-13` 起,修改密码成功后必须撤销该用户全部 active `refresh_session`,并在响应中清除当前 refresh cookie;前端清空本地 access token 并回到未登录态。用户需要使用新密码重新登录。
|
||||
|
||||
### 2.3 重置密码
|
||||
|
||||
@@ -79,7 +80,7 @@
|
||||
|
||||
## 5. 2026-05-12 快照同步修复
|
||||
|
||||
重置密码和修改密码都会改变认证真相:`password_hash`、`password_login_enabled`、`token_version`,重置密码还会立即创建新的 refresh session。因此 API 层在 `POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后,必须和密码登录、手机号登录、刷新、退出一样调用 `sync_auth_store_snapshot_to_spacetime()`。
|
||||
重置密码和修改密码都会改变认证真相:`password_hash`、`password_login_enabled`、`token_version`,重置密码还会立即创建新的 refresh session,修改密码还会撤销全部旧 refresh session。因此 API 层在 `POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后,必须和密码登录、手机号登录、刷新、退出一样调用 `sync_auth_store_snapshot_to_spacetime()`。
|
||||
|
||||
若只更新本地 `InMemoryAuthStore` 而不同步 SpacetimeDB 认证快照,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态,表现为用户已通过忘记密码重设成功,但再次密码登录仍返回“手机号或密码错误”。启动恢复时应从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照;当本地文件更新且远端表无更新时间戳时,优先使用本地文件并尝试回写 SpacetimeDB,避免旧远端状态覆盖刚重设的密码。
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
- [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md):`/api/auth/login-options` 首版设计,冻结登录方式列表 contract、配置开关来源与返回顺序。
|
||||
- [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_LOGOUT_ALL_DESIGN_2026-04-21.md](./AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md):`/api/auth/logout-all` 全端登出设计,冻结全部 refresh session 吊销、`token_version` 递增、清 cookie 语义与 Rust 首版接口边界。
|
||||
- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。
|
||||
- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、同设备同 IP 会话组合并、`clientLabel` 兼容策略与 Rust 接口边界。
|
||||
- [PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md):手机号验证码登录最小闭环设计,冻结 mock 验证码规则、`send-code` / `phone/login` contract 与 crate 边界。
|
||||
- [PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md):手机号验证码冷却与失败次数限制设计,冻结同手机号同场景发送冷却、错误次数耗尽、`429` 与 `Retry-After` contract。
|
||||
- [PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](./PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md):冻结 Rust `api-server + module-auth + platform-auth` 接入真实阿里云短信 provider 的 crate 边界、发送与校验职责、配置项和错误语义。
|
||||
@@ -207,7 +207,7 @@
|
||||
- [SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md):`M2` 第六张短信鉴权统计表 `sms_auth_event` 的事件范围、统计口径、索引与和风控/审计表的协作边界。
|
||||
- [SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md):`M2` 第五张风控状态表 `auth_risk_block` 的作用域、活跃态、刷新/解除规则与读取派生约束。
|
||||
- [SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md):`M2` 第四张鉴权审计表 `auth_audit_log` 的事件范围、追加写规则、索引与对外 DTO 派生约束。
|
||||
- [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md):`M2` 第三张会话表 `refresh_session` 的 cookie/hash 边界、轮换与吊销语义、索引与迁移规则。
|
||||
- [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md):`M2` 第三张会话表 `refresh_session` 的 cookie/hash 边界、轮换、logout fallback、指定会话吊销语义、索引与迁移规则。
|
||||
- [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md):`M2` 第二张身份表 `auth_identity` 的 provider 范围、唯一约束、手机号/微信身份写入规则与迁移策略。
|
||||
- [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md):`M2` 第一张身份主表 `user_account` 的职责边界、字段、唯一约束、状态迁移、旧 `users` 映射与落地约束。
|
||||
- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于旧 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Rust API Server 路由索引(2026-04-23)
|
||||
|
||||
更新时间:`2026-05-01`
|
||||
更新时间:`2026-05-13`
|
||||
|
||||
> 2026-04-29 补充:本文件保留为迁移期路由快照。DDD G1 后续并行工作的契约冻结口径以 [`SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`](./SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md) 为准,尤其是新增的 Big Fish、Puzzle、profile、runtime chat、story facade 和兼容路由删除计划。
|
||||
>
|
||||
@@ -20,7 +20,7 @@
|
||||
2. 内部鉴权调试接口:`2` 条。
|
||||
3. AI task 接口:`9` 条。
|
||||
4. assets / OSS 接口:`15` 条。
|
||||
5. auth 接口:`12` 条。
|
||||
5. auth 接口:`13` 条。
|
||||
6. custom world / agent 接口:`23` 条。
|
||||
7. match3d creation / runtime 接口:`14` 条。
|
||||
8. llm proxy 接口:`1` 条。
|
||||
@@ -84,13 +84,14 @@
|
||||
3. `POST /api/auth/logout`
|
||||
4. `POST /api/auth/logout-all`
|
||||
5. `GET /api/auth/sessions`
|
||||
6. `POST /api/auth/refresh`
|
||||
7. `POST /api/auth/phone/send-code`
|
||||
8. `POST /api/auth/phone/login`
|
||||
9. `GET /api/auth/wechat/start`
|
||||
10. `GET /api/auth/wechat/callback`
|
||||
11. `POST /api/auth/wechat/bind-phone`
|
||||
12. `POST /api/auth/entry`
|
||||
6. `POST /api/auth/sessions/{session_id}/revoke`
|
||||
7. `POST /api/auth/refresh`
|
||||
8. `POST /api/auth/phone/send-code`
|
||||
9. `POST /api/auth/phone/login`
|
||||
10. `GET /api/auth/wechat/start`
|
||||
11. `GET /api/auth/wechat/callback`
|
||||
12. `POST /api/auth/wechat/bind-phone`
|
||||
13. `POST /api/auth/entry`
|
||||
|
||||
### 3.6 Custom World / Agent
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ G1 单 owner 文件范围:
|
||||
| 管理兑换码 | `POST /admin/api/profile/redeem-codes`、`POST /admin/api/profile/redeem-codes/disable` | 收敛 | 继续走 admin 路由,DTO 归入 profile/runtime 管理命令组 | WP-RT、WP-API |
|
||||
| 内部鉴权调试 | `GET /_internal/auth/claims`、`GET /_internal/auth/refresh-cookie` | 删除 | 只允许本地诊断脚本或 admin debug 能力使用,不作为前端契约 | WP-DEL |
|
||||
| 鉴权公开查询 | `GET /api/auth/login-options`、`GET /api/auth/public-users/by-code/{code}`、`GET /api/auth/public-users/by-id/{user_id}` | 保留 | `AuthLoginOptionsResponse`、`PublicUserSearchResponse` | WP-A |
|
||||
| 鉴权会话 | `GET /api/auth/me`、`GET /api/auth/sessions`、`POST /api/auth/refresh`、`POST /api/auth/logout`、`POST /api/auth/logout-all` | 保留 | `AuthMeResponse`、`AuthSessionsResponse`、`RefreshSessionResponse`、`LogoutResponse`、`LogoutAllResponse` | WP-A |
|
||||
| 鉴权会话 | `GET /api/auth/me`、`GET /api/auth/sessions`、`POST /api/auth/sessions/{session_id}/revoke`、`POST /api/auth/refresh`、`POST /api/auth/logout`、`POST /api/auth/logout-all` | 保留 | `AuthMeResponse`、`AuthSessionsResponse`、`RevokeAuthSessionResponse`、`RefreshSessionResponse`、`LogoutResponse`、`LogoutAllResponse` | WP-A |
|
||||
| 鉴权登录 | `POST /api/auth/phone/send-code`、`POST /api/auth/phone/login`、`GET /api/auth/wechat/start`、`GET /api/auth/wechat/callback`、`POST /api/auth/wechat/bind-phone`、`POST /api/auth/entry`、`POST /api/auth/password/change`、`POST /api/auth/password/reset` | 保留 | TS 命名统一使用 `Auth*` 前缀,Rust 命名维持领域语义 | WP-A |
|
||||
| 旧本地生成资产代理 | `GET /generated-character-drafts/{*path}`、`/generated-characters/{*path}`、`/generated-animations/{*path}`、`/generated-big-fish-assets/{*path}`、`/generated-puzzle-assets/{*path}`、`/generated-custom-world-scenes/{*path}`、`/generated-custom-world-covers/{*path}`、`/generated-qwen-sprites/{*path}` | 已删除 | 正式读取统一走 `GET /api/assets/read-url` 或 asset object projection;`/generated-*` 仅允许作为 legacyPublicPath/object key 标识,不再作为可裸读路由 | WP-AS、WP-FE、WP-DEL |
|
||||
| LLM 代理 | `POST /api/llm/chat/completions` | 收敛 | 仅作为平台能力代理;玩法 prompt 不允许由前端直接传入 | WP-PF、WP-API |
|
||||
@@ -59,7 +59,7 @@ G1 单 owner 文件范围:
|
||||
| --- | --- |
|
||||
| `shared-contracts/src/api.rs` | `ApiResponseMeta`、`ApiErrorPayload`、`ApiSuccessEnvelope<T>`、`ApiErrorEnvelope` |
|
||||
| `shared-contracts/src/admin.rs` | `AdminLoginRequest/Response`、`AdminSessionPayload`、`AdminMeResponse`、`AdminOverviewResponse`、`AdminDebugHttpRequest/Response` |
|
||||
| `shared-contracts/src/auth.rs` | `AuthLoginOptionsResponse`、`AuthUserPayload`、`PublicUserSummaryPayload`、`PublicUserSearchResponse`、`PasswordEntry*`、`PasswordChange*`、`PasswordReset*`、`AuthMeResponse`、`AuthSessionsResponse`、`RefreshSessionResponse`、`Logout*`、`Phone*`、`Wechat*` |
|
||||
| `shared-contracts/src/auth.rs` | `AuthLoginOptionsResponse`、`AuthUserPayload`、`PublicUserSummaryPayload`、`PublicUserSearchResponse`、`PasswordEntry*`、`PasswordChange*`、`PasswordReset*`、`AuthMeResponse`、`AuthSessionsResponse`、`RevokeAuthSessionResponse`、`RefreshSessionResponse`、`Logout*`、`Phone*`、`Wechat*` |
|
||||
| `shared-contracts/src/ai.rs` | `CreateAiTaskRequest`、`AppendAiTextChunkRequest`、`CompleteAiStageRequest`、`AttachAiResultReferenceRequest`、`FailAiTaskRequest`、`AiTask*Payload`、`AiTaskMutationResponse`、`AiTaskAcceptedResponse` |
|
||||
| `shared-contracts/src/assets.rs` | Direct upload、read url、asset object、asset binding、asset history、character visual/animation、workflow cache、role asset workflow 相关 DTO |
|
||||
| `shared-contracts/src/creation_agent_document_input.rs` | `ParseCreationAgentDocumentInputRequest/Response`、`CreationAgentDocumentInputPayload` |
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
1. 从 cookie 读出原始 refresh token
|
||||
2. 计算 hash
|
||||
3. 与 `refresh_session.refresh_token_hash` 比较
|
||||
4. 若 refresh cookie 缺失或不可用,再使用 Bearer access token claims 中的 `sid` 与 `refresh_session.session_id` 比较
|
||||
5. 会话列表按“同设备 + 同 IP”聚合时,组内任一 session 命中当前 hash 或当前 `sid`,整组都视为当前设备组
|
||||
|
||||
## 5. 表访问级别
|
||||
|
||||
@@ -228,9 +230,10 @@
|
||||
写入规则:
|
||||
|
||||
1. 按当前 cookie 找 session
|
||||
2. 写 `revoked_at = now`
|
||||
3. 写 `revoked_reason_code = logout`
|
||||
4. 同时提升 `user_account.token_version`
|
||||
2. 如果 refresh cookie 缺失,则回退用 Bearer access token claims 中的 `sid` 找当前 session
|
||||
3. 写 `revoked_at = now`
|
||||
4. 写 `revoked_reason_code = logout`
|
||||
5. 同时提升 `user_account.token_version`
|
||||
|
||||
### 8.4 吊销全部会话
|
||||
|
||||
@@ -248,7 +251,7 @@
|
||||
|
||||
触发点:
|
||||
|
||||
1. `POST /api/auth/sessions/:sessionId/revoke`
|
||||
1. `POST /api/auth/sessions/{sessionId}/revoke`
|
||||
|
||||
写入规则:
|
||||
|
||||
@@ -257,6 +260,13 @@
|
||||
3. 只改目标 `refresh_session`
|
||||
4. `revoked_reason_code = session_revoke`
|
||||
5. 不提升 `token_version`
|
||||
6. 撤销后必须同步 auth store 到 SpacetimeDB
|
||||
|
||||
读取约束:
|
||||
|
||||
1. Bearer JWT 中的 `sid` 必须对应 active `refresh_session`
|
||||
2. 被该接口撤销的设备即使 access token 未过期,后续请求也必须立刻返回未授权
|
||||
3. 该接口不承担当前设备退出语义;当前设备退出固定走 `/api/auth/logout`
|
||||
|
||||
### 8.6 账号被禁用或并入
|
||||
|
||||
@@ -315,13 +325,18 @@
|
||||
|
||||
1. `clientLabel` 当前阶段继续兼容保留,但固定与 `deviceDisplayName` 对齐。
|
||||
2. `ipMasked`、`isCurrent` 继续在 Axum 侧派生。
|
||||
3. 同设备同 IP 的 active sessions 由 Axum 聚合后返回一条记录。
|
||||
4. `sessionId` 是代表 ID;当前组代表 ID 使用当前 `sid` 对应 session。
|
||||
5. `sessionIds` 返回组内全部 active session ID,`sessionCount` 返回组内数量。
|
||||
6. 聚合组时间语义:`createdAt` 取最早创建时间,`lastSeenAt` 与 `expiresAt` 取最新值。
|
||||
|
||||
### 10.3 `POST /api/auth/logout`
|
||||
|
||||
依赖:
|
||||
|
||||
1. 当前 cookie 命中的 `refresh_session`
|
||||
2. `user_account.token_version`
|
||||
2. cookie 缺失时 Bearer `sid` 命中的 `refresh_session`
|
||||
3. `user_account.token_version`
|
||||
|
||||
### 10.4 `POST /api/auth/logout-all`
|
||||
|
||||
@@ -330,6 +345,22 @@
|
||||
1. 当前 `user_id` 下全部活跃 `refresh_session`
|
||||
2. `user_account.token_version`
|
||||
|
||||
### 10.5 `POST /api/auth/sessions/{sessionId}/revoke`
|
||||
|
||||
依赖:
|
||||
|
||||
1. 当前 Bearer JWT 的 `user_id`
|
||||
2. 当前 Bearer JWT 的 `sid`
|
||||
3. 目标 `refresh_session.session_id`
|
||||
4. `refresh_session.revoked_at`
|
||||
5. `refresh_session.expires_at`
|
||||
|
||||
固定行为:
|
||||
|
||||
1. 目标 session 必须属于当前用户
|
||||
2. 目标 session 不能是当前 `sid`
|
||||
3. 成功只撤销目标 session,不递增 `token_version`
|
||||
|
||||
## 11. 与当前 Node `user_sessions` 的映射关系
|
||||
|
||||
| Node `user_sessions` 列 | 新 `refresh_session` 字段 | 迁移规则 |
|
||||
|
||||
@@ -150,6 +150,8 @@ export type AuthRefreshResponse = {
|
||||
|
||||
export type AuthSessionSummary = {
|
||||
sessionId: string;
|
||||
sessionIds: string[];
|
||||
sessionCount: number;
|
||||
clientType: string;
|
||||
clientRuntime: string;
|
||||
clientPlatform: string;
|
||||
|
||||
@@ -776,7 +776,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_ai_tasks".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_ai_tasks"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,6 +117,34 @@ pub async fn require_bearer_auth(
|
||||
.with_message("当前登录态已失效,请重新登录"));
|
||||
}
|
||||
|
||||
let session_is_active = state
|
||||
.refresh_session_service()
|
||||
.is_session_active_for_user(
|
||||
claims.user_id(),
|
||||
claims.session_id(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
warn!(
|
||||
%request_id,
|
||||
user_id = %claims.user_id(),
|
||||
session_id = %claims.session_id(),
|
||||
error = %error,
|
||||
"Bearer JWT refresh session 状态读取失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
})?;
|
||||
if !session_is_active {
|
||||
warn!(
|
||||
%request_id,
|
||||
user_id = %claims.user_id(),
|
||||
session_id = %claims.session_id(),
|
||||
"Bearer JWT 对应 refresh session 已失效"
|
||||
);
|
||||
return Err(AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("当前登录态已失效,请重新登录"));
|
||||
}
|
||||
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(AuthenticatedAccessToken::new(claims.clone()));
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use module_auth::{RefreshSessionRecord, RevokeRefreshSessionByUserInput};
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse};
|
||||
use shared_contracts::auth::{
|
||||
AuthSessionSummaryPayload, AuthSessionsResponse, RevokeAuthSessionResponse,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
@@ -37,41 +42,189 @@ pub async fn auth_sessions(
|
||||
.refresh_session_service()
|
||||
.list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc())
|
||||
.map_err(map_refresh_session_list_error)?;
|
||||
let current_session_id = authenticated.claims().session_id().to_string();
|
||||
let session_groups = group_sessions_by_device_and_ip(sessions.sessions);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AuthSessionsResponse {
|
||||
sessions: sessions
|
||||
.sessions
|
||||
sessions: session_groups
|
||||
.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,
|
||||
}
|
||||
.map(|group| {
|
||||
build_session_summary(
|
||||
group,
|
||||
current_refresh_token_hash.as_deref(),
|
||||
¤t_session_id,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn revoke_auth_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Path(session_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let session_id = session_id.trim().to_string();
|
||||
if session_id.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少会话 ID"));
|
||||
}
|
||||
if session_id == authenticated.claims().session_id() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::CONFLICT).with_message("当前设备请使用退出登录")
|
||||
);
|
||||
}
|
||||
|
||||
let revoke_result = state
|
||||
.refresh_session_service()
|
||||
.revoke_session_by_user_and_session(
|
||||
RevokeRefreshSessionByUserInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
session_id,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(map_refresh_session_revoke_error)?;
|
||||
if !revoke_result.revoked {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("会话不存在或已失效")
|
||||
);
|
||||
}
|
||||
state
|
||||
.sync_auth_store_snapshot_to_spacetime()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
RevokeAuthSessionResponse { ok: true },
|
||||
))
|
||||
}
|
||||
|
||||
fn group_sessions_by_device_and_ip(
|
||||
sessions: Vec<RefreshSessionRecord>,
|
||||
) -> Vec<Vec<RefreshSessionRecord>> {
|
||||
let mut grouped = HashMap::<String, Vec<RefreshSessionRecord>>::new();
|
||||
for session in sessions {
|
||||
grouped
|
||||
.entry(build_session_group_key(&session))
|
||||
.or_default()
|
||||
.push(session);
|
||||
}
|
||||
|
||||
let mut groups = grouped.into_values().collect::<Vec<_>>();
|
||||
for group in &mut groups {
|
||||
group.sort_by(|left, right| {
|
||||
right
|
||||
.last_seen_at
|
||||
.cmp(&left.last_seen_at)
|
||||
.then_with(|| right.created_at.cmp(&left.created_at))
|
||||
});
|
||||
}
|
||||
groups.sort_by(|left, right| {
|
||||
group_latest_last_seen(right)
|
||||
.cmp(group_latest_last_seen(left))
|
||||
.then_with(|| group_earliest_created(left).cmp(group_earliest_created(right)))
|
||||
});
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
fn build_session_group_key(session: &RefreshSessionRecord) -> String {
|
||||
let client_info = &session.client_info;
|
||||
let device_key = client_info.device_fingerprint.as_deref().unwrap_or("");
|
||||
if !device_key.is_empty() {
|
||||
return format!("{}|{}", device_key, client_info.ip.as_deref().unwrap_or(""));
|
||||
}
|
||||
|
||||
format!(
|
||||
"{}|{}|{}|{}|{}|{}",
|
||||
client_info.client_type,
|
||||
client_info.client_runtime,
|
||||
client_info.client_platform,
|
||||
client_info.device_display_name,
|
||||
client_info.user_agent.as_deref().unwrap_or(""),
|
||||
client_info.ip.as_deref().unwrap_or("")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_session_summary(
|
||||
group: Vec<RefreshSessionRecord>,
|
||||
current_refresh_token_hash: Option<&str>,
|
||||
current_session_id: &str,
|
||||
) -> AuthSessionSummaryPayload {
|
||||
let is_current = group.iter().any(|session| {
|
||||
session.session_id == current_session_id
|
||||
|| current_refresh_token_hash.is_some_and(|hash| session.refresh_token_hash == hash)
|
||||
});
|
||||
let representative = group
|
||||
.iter()
|
||||
.find(|session| is_current && session.session_id == current_session_id)
|
||||
.or_else(|| {
|
||||
group.iter().find(|session| {
|
||||
is_current
|
||||
&& current_refresh_token_hash
|
||||
.is_some_and(|hash| session.refresh_token_hash == hash)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| group.first().expect("session group should not be empty"));
|
||||
let client_label = representative.client_info.device_display_name.clone();
|
||||
let session_ids = group
|
||||
.iter()
|
||||
.map(|session| session.session_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let session_count = u32::try_from(session_ids.len()).unwrap_or(u32::MAX);
|
||||
|
||||
AuthSessionSummaryPayload {
|
||||
session_id: representative.session_id.clone(),
|
||||
session_ids,
|
||||
session_count,
|
||||
client_type: representative.client_info.client_type.clone(),
|
||||
client_runtime: representative.client_info.client_runtime.clone(),
|
||||
client_platform: representative.client_info.client_platform.clone(),
|
||||
client_label,
|
||||
device_display_name: representative.client_info.device_display_name.clone(),
|
||||
mini_program_app_id: representative.client_info.mini_program_app_id.clone(),
|
||||
mini_program_env: representative.client_info.mini_program_env.clone(),
|
||||
user_agent: representative.client_info.user_agent.clone(),
|
||||
ip_masked: mask_ip(representative.client_info.ip.as_deref()),
|
||||
is_current,
|
||||
created_at: group_earliest_created(&group).to_string(),
|
||||
last_seen_at: group_latest_last_seen(&group).to_string(),
|
||||
expires_at: group_latest_expires_at(&group).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn group_latest_last_seen(group: &[RefreshSessionRecord]) -> &str {
|
||||
group
|
||||
.iter()
|
||||
.map(|session| session.last_seen_at.as_str())
|
||||
.max()
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn group_earliest_created(group: &[RefreshSessionRecord]) -> &str {
|
||||
group
|
||||
.iter()
|
||||
.map(|session| session.created_at.as_str())
|
||||
.min()
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn group_latest_expires_at(group: &[RefreshSessionRecord]) -> &str {
|
||||
group
|
||||
.iter()
|
||||
.map(|session| session.expires_at.as_str())
|
||||
.max()
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError {
|
||||
match error {
|
||||
module_auth::RefreshSessionError::UserNotFound => {
|
||||
@@ -88,3 +241,19 @@ fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> Ap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_refresh_session_revoke_error(error: module_auth::RefreshSessionError) -> AppError {
|
||||
match error {
|
||||
module_auth::RefreshSessionError::MissingToken
|
||||
| module_auth::RefreshSessionError::SessionNotFound => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
|
||||
}
|
||||
module_auth::RefreshSessionError::SessionExpired
|
||||
| module_auth::RefreshSessionError::UserNotFound => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,14 +375,15 @@ mod tests {
|
||||
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: user.id,
|
||||
session_id: "sess_creation_doc_input".to_string(),
|
||||
user_id: user.id.clone(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user(&user, "sess_creation_doc_input"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: user.token_version,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some(user.display_name),
|
||||
display_name: Some(user.display_name.clone()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
|
||||
@@ -333,7 +333,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_llm_proxy".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_llm_proxy"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -40,6 +40,7 @@ pub async fn logout(
|
||||
LogoutCurrentSessionInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
refresh_token_hash,
|
||||
session_id: Some(authenticated.claims().session_id().to_string()),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
|
||||
@@ -15,7 +15,8 @@ use crate::{
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth_payload::map_auth_user_payload,
|
||||
auth_session::{
|
||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
|
||||
build_refresh_session_cookie_header, create_auth_session,
|
||||
record_daily_login_tracking_event_after_auth_success,
|
||||
},
|
||||
http_error::AppError,
|
||||
@@ -30,14 +31,17 @@ pub async fn change_password(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<PasswordChangeRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let result = state
|
||||
.password_entry_service()
|
||||
.change_password(ChangePasswordInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
current_password: payload.current_password,
|
||||
new_password: payload.new_password,
|
||||
})
|
||||
.change_password_and_revoke_all_sessions(
|
||||
ChangePasswordInput {
|
||||
user_id: authenticated.claims().user_id().to_string(),
|
||||
current_password: payload.current_password,
|
||||
new_password: payload.new_password,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_password_management_error)?;
|
||||
state
|
||||
@@ -48,11 +52,20 @@ pub async fn change_password(
|
||||
.with_message(format!("同步认证快照失败:{error}"))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PasswordChangeResponse {
|
||||
user: map_auth_user_payload(result.user),
|
||||
},
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
&mut headers,
|
||||
build_clear_refresh_session_cookie_header(&state)?,
|
||||
);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
PasswordChangeResponse {
|
||||
user: map_auth_user_payload(result.user),
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -374,7 +374,10 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_browse_history".to_string(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(
|
||||
"user_00000001",
|
||||
"sess_runtime_browse_history",
|
||||
),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -174,7 +174,10 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_inventory".to_string(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(
|
||||
"user_00000001",
|
||||
"sess_runtime_inventory",
|
||||
),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -1568,7 +1568,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_profile".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -575,7 +575,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_save".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_save"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -350,7 +350,10 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_runtime_settings".to_string(),
|
||||
session_id: state.seed_test_refresh_session_for_user_id(
|
||||
"user_00000001",
|
||||
"sess_runtime_settings",
|
||||
),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -600,6 +600,54 @@ impl AppState {
|
||||
|
||||
#[cfg(test)]
|
||||
impl AppState {
|
||||
pub(crate) fn seed_test_refresh_session_for_user(
|
||||
&self,
|
||||
user: &module_auth::AuthUser,
|
||||
seed: &str,
|
||||
) -> String {
|
||||
let session = self
|
||||
.refresh_session_service()
|
||||
.create_session(
|
||||
module_auth::CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: platform_auth::hash_refresh_session_token(&format!(
|
||||
"test-refresh-token-{seed}"
|
||||
)),
|
||||
issued_by_provider: module_auth::AuthLoginMethod::Password,
|
||||
client_info: module_auth::RefreshSessionClientInfo {
|
||||
client_type: "web_browser".to_string(),
|
||||
client_runtime: "test".to_string(),
|
||||
client_platform: "test".to_string(),
|
||||
client_instance_id: Some(seed.to_string()),
|
||||
device_fingerprint: Some(format!("test-device-{seed}")),
|
||||
device_display_name: "Test Browser".to_string(),
|
||||
mini_program_app_id: None,
|
||||
mini_program_env: None,
|
||||
user_agent: Some("GenarrativeApiServerTest/1.0".to_string()),
|
||||
ip: Some("127.0.0.1".to_string()),
|
||||
},
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("test refresh session should create");
|
||||
|
||||
session.session.session_id
|
||||
}
|
||||
|
||||
pub(crate) fn seed_test_refresh_session_for_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
seed: &str,
|
||||
) -> String {
|
||||
let user = self
|
||||
.auth_user_service()
|
||||
.get_user_by_id(user_id)
|
||||
.expect("test user lookup should succeed")
|
||||
.expect("test user should exist");
|
||||
|
||||
self.seed_test_refresh_session_for_user(&user, seed)
|
||||
}
|
||||
|
||||
fn cache_test_creation_entry_config(&self, config: CreationEntryConfigResponse) {
|
||||
*self
|
||||
.test_creation_entry_config
|
||||
|
||||
@@ -959,7 +959,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_story_battles".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_story_battles"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
@@ -1132,7 +1132,8 @@ mod tests {
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_story_sessions".to_string(),
|
||||
session_id: state
|
||||
.seed_test_refresh_session_for_user_id("user_00000001", "sess_story_sessions"),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 2,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user