Compare commits
2 Commits
b13870f71b
...
a92dc2b7b0
| Author | SHA1 | Date | |
|---|---|---|---|
| a92dc2b7b0 | |||
| 4fecf9c975 |
@@ -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 常见素材风格
|
## 2026-05-12 抓大鹅入口素材风格改为 2D 常见素材风格
|
||||||
|
|
||||||
- 背景:抓大鹅草稿素材生成已经收敛为多视角 2D 图片素材,但入口页和旧参考图仍沿用黏土、低多边形、塑料、木雕、体素、金属等偏 3D 素材语言,容易让后续生成链路和用户预期继续漂移。
|
- 背景:抓大鹅草稿素材生成已经收敛为多视角 2D 图片素材,但入口页和旧参考图仍沿用黏土、低多边形、塑料、木雕、体素、金属等偏 3D 素材语言,容易让后续生成链路和用户预期继续漂移。
|
||||||
@@ -55,7 +71,6 @@
|
|||||||
- 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。
|
- 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。
|
||||||
- 验证方式:执行 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、触碰文件 ESLint、`npm run check:encoding`。
|
- 验证方式:执行 `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`。
|
- 关联文档:`docs/prd/PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md`。
|
||||||
|
|
||||||
## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权
|
## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权
|
||||||
|
|
||||||
- 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`,H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。
|
- 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`,H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。
|
||||||
|
|||||||
@@ -438,6 +438,14 @@
|
|||||||
- 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。
|
- 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。
|
||||||
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||||
|
|
||||||
|
## Jenkins 生产流水线拉 Git 先本机再内网备用
|
||||||
|
|
||||||
|
- 现象:生产发布、数据库导入导出或服务器配置流水线在目标 Linux agent 上执行 `GitSCM checkout` 时,`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达,导致脚本还没拉下来就失败。
|
||||||
|
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。
|
||||||
|
- 处理:需要在运行于内网 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后再尝试 `GIT_REMOTE_FALLBACK_URL=http://10.2.0.10:3000/GenarrativeAI/Genarrative.git`;后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址。`10.2.0.10` 是内网地址,Windows controller 或 Windows 构建节点不要接入这个 fallback。
|
||||||
|
- 验证:扫描所有以 `127.0.0.1:3000` 拉 Git 且运行在 Linux agent 的生产 Jenkinsfile,确认存在 `GIT_REMOTE_FALLBACK_URL`、`EFFECTIVE_GIT_REMOTE_URL` 和脚本层 `GIT_REMOTE_FALLBACK_URL` 透传;运行 `bash -n scripts/jenkins-checkout-source.sh`。
|
||||||
|
- 关联:`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`jenkins/Jenkinsfile.production-server-provision`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、`scripts/jenkins-checkout-source.sh`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||||
|
|
||||||
## Jenkins 可选参数在 set -u 下不能裸读
|
## Jenkins 可选参数在 set -u 下不能裸读
|
||||||
|
|
||||||
- 现象:数据库导入或导出流水线报 `INCLUDE_TABLES: unbound variable`,或其它可选参数在 Bash 中未定义即退出。
|
- 现象:数据库导入或导出流水线报 `INCLUDE_TABLES: unbound variable`,或其它可选参数在 Bash 中未定义即退出。
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
2. 当前设备识别方式与 `isCurrent` 语义
|
2. 当前设备识别方式与 `isCurrent` 语义
|
||||||
3. 多端登录识别字段如何从 `refresh_session` 派生到 DTO
|
3. 多端登录识别字段如何从 `refresh_session` 派生到 DTO
|
||||||
4. Rust 首版在 Axum + 进程内 `module-auth` 下的最小实现边界
|
4. Rust 首版在 Axum + 进程内 `module-auth` 下的最小实现边界
|
||||||
|
5. `2026-05-13` 会话组合并展示与远端踢下线闭环修复口径
|
||||||
|
|
||||||
## 2. 当前基线
|
## 2. 当前基线
|
||||||
|
|
||||||
@@ -46,11 +47,16 @@
|
|||||||
3. 登录创建 session 时落库结构化客户端身份字段
|
3. 登录创建 session 时落库结构化客户端身份字段
|
||||||
4. 会话列表返回多端识别所需字段,并兼容旧 `clientLabel`
|
4. 会话列表返回多端识别所需字段,并兼容旧 `clientLabel`
|
||||||
|
|
||||||
本阶段明确不包含:
|
`2026-05-13` 起,本接口同时承担账号安全页的会话组读模型:
|
||||||
|
|
||||||
1. `/api/auth/sessions/:sessionId/revoke`
|
1. 后端按“同设备 + 同 IP”聚合活跃 `refresh_session`
|
||||||
2. 前端完整消费全部新增字段
|
2. 前端只消费后端聚合结果,不自行推断合并
|
||||||
3. SpacetimeDB reducer / view 正式读表
|
3. `POST /api/auth/sessions/{sessionId}/revoke` 已纳入 Rust 实现,用于踢下线非当前会话
|
||||||
|
|
||||||
|
本阶段仍明确不包含:
|
||||||
|
|
||||||
|
1. SpacetimeDB reducer / view 正式读表
|
||||||
|
2. 登录方式、refresh token 轮换策略或账号安全页整体重设计
|
||||||
|
|
||||||
## 5. 请求与响应 contract
|
## 5. 请求与响应 contract
|
||||||
|
|
||||||
@@ -70,6 +76,8 @@
|
|||||||
"sessions": [
|
"sessions": [
|
||||||
{
|
{
|
||||||
"sessionId": "usess_xxx",
|
"sessionId": "usess_xxx",
|
||||||
|
"sessionIds": ["usess_xxx", "usess_yyy"],
|
||||||
|
"sessionCount": 2,
|
||||||
"clientType": "web_browser",
|
"clientType": "web_browser",
|
||||||
"clientRuntime": "chrome",
|
"clientRuntime": "chrome",
|
||||||
"clientPlatform": "windows",
|
"clientPlatform": "windows",
|
||||||
@@ -90,9 +98,12 @@
|
|||||||
|
|
||||||
字段说明:
|
字段说明:
|
||||||
|
|
||||||
1. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致
|
1. `sessionId` 是聚合组代表会话 ID;若组内包含当前 `sid`,代表 ID 必须使用当前会话 ID
|
||||||
2. `clientRuntime`、`clientPlatform`、`deviceDisplayName` 是多端识别首版最小新增字段
|
2. `sessionIds` 是该聚合组内全部活跃 session ID,前端批量踢下线时逐个调用 revoke
|
||||||
3. 小程序来源额外暴露 `miniProgramAppId`、`miniProgramEnv`
|
3. `sessionCount` 是聚合组内 session 数量
|
||||||
|
4. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致
|
||||||
|
5. `clientRuntime`、`clientPlatform`、`deviceDisplayName` 是多端识别首版最小新增字段
|
||||||
|
6. 小程序来源额外暴露 `miniProgramAppId`、`miniProgramEnv`
|
||||||
|
|
||||||
### 5.3 失败响应
|
### 5.3 失败响应
|
||||||
|
|
||||||
@@ -110,12 +121,25 @@
|
|||||||
1. 从 refresh cookie 读取当前原始 refresh token
|
1. 从 refresh cookie 读取当前原始 refresh token
|
||||||
2. 在 Axum 侧计算 `sha256(refresh_token)`
|
2. 在 Axum 侧计算 `sha256(refresh_token)`
|
||||||
3. 与会话列表中的 `refresh_token_hash` 比较
|
3. 与会话列表中的 `refresh_token_hash` 比较
|
||||||
4. 命中则 `isCurrent = true`
|
4. 同时读取 Bearer access token claims 中的 `sid`
|
||||||
|
5. 聚合组内任意 session 命中当前 refresh hash 或当前 `sid`,则整组 `isCurrent = true`
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
1. 如果请求没有携带 refresh cookie,本接口仍可返回会话列表
|
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. 多端标识派生规则
|
## 7. 多端标识派生规则
|
||||||
|
|
||||||
@@ -161,8 +185,21 @@
|
|||||||
负责:
|
负责:
|
||||||
|
|
||||||
1. 读取 Bearer JWT 与 refresh cookie
|
1. 读取 Bearer JWT 与 refresh cookie
|
||||||
2. 把活跃会话映射成旧接口兼容 DTO
|
2. 按同设备同 IP 聚合活跃会话
|
||||||
3. 派生 `ipMasked` 与 `isCurrent`
|
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. 测试策略
|
## 9. 测试策略
|
||||||
|
|
||||||
@@ -172,6 +209,9 @@
|
|||||||
2. 微信内 H5 登录后,会话列表返回 `wechat_h5 + wechat_embedded_browser`
|
2. 微信内 H5 登录后,会话列表返回 `wechat_h5 + wechat_embedded_browser`
|
||||||
3. 显式小程序头优先于 `User-Agent` 判断
|
3. 显式小程序头优先于 `User-Agent` 判断
|
||||||
4. 请求携带当前 refresh cookie 时,只有当前会话 `isCurrent = true`
|
4. 请求携带当前 refresh cookie 时,只有当前会话 `isCurrent = true`
|
||||||
|
5. 同设备同 IP 会话会合并,并返回 `sessionIds/sessionCount`
|
||||||
|
6. 合并组包含当前 `sid` 或当前 refresh hash 时,整组 `isCurrent = true`
|
||||||
|
7. 指定远端会话吊销后,被踢设备 access token 立即无法通过认证
|
||||||
|
|
||||||
## 10. 完成定义
|
## 10. 完成定义
|
||||||
|
|
||||||
@@ -181,4 +221,6 @@
|
|||||||
2. 会话列表可区分普通浏览器、微信内 H5、小程序来源
|
2. 会话列表可区分普通浏览器、微信内 H5、小程序来源
|
||||||
3. 同设备不同浏览器可在会话列表中清晰区分
|
3. 同设备不同浏览器可在会话列表中清晰区分
|
||||||
4. `clientLabel` 与新增多端字段都已稳定返回
|
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_phone_login_success` | `POST /api/auth/phone/login` |
|
||||||
| `auth_me_view` | `GET /api/auth/me` |
|
| `auth_me_view` | `GET /api/auth/me` |
|
||||||
| `auth_sessions_view` | `GET /api/auth/sessions` |
|
| `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_refresh_success` | `POST /api/auth/refresh` |
|
||||||
| `auth_logout` | `POST /api/auth/logout` |
|
| `auth_logout` | `POST /api/auth/logout` |
|
||||||
| `auth_logout_all` | `POST /api/auth/logout-all` |
|
| `auth_logout_all` | `POST /api/auth/logout-all` |
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
2. 请求字段:`currentPassword`、`newPassword`。
|
2. 请求字段:`currentPassword`、`newPassword`。
|
||||||
3. 若账号未设置过密码,允许 `currentPassword` 为空并设置首个密码。
|
3. 若账号未设置过密码,允许 `currentPassword` 为空并设置首个密码。
|
||||||
4. 若账号已有密码,必须校验 `currentPassword` 后才能写入 `newPassword`。
|
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 重置密码
|
### 2.3 重置密码
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
|
|
||||||
## 5. 2026-05-12 快照同步修复
|
## 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,避免旧远端状态覆盖刚重设的密码。
|
若只更新本地 `InMemoryAuthStore` 而不同步 SpacetimeDB 认证快照,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态,表现为用户已通过忘记密码重设成功,但再次密码登录仍返回“手机号或密码错误”。启动恢复时应从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照;当本地文件更新且远端表无更新时间戳时,优先使用本地文件并尝试回写 SpacetimeDB,避免旧远端状态覆盖刚重设的密码。
|
||||||
|
|
||||||
|
|||||||
@@ -132,8 +132,8 @@ SpacetimeDB 公网路由默认保持收敛,只按实际前端 SDK 需要暴露
|
|||||||
|
|
||||||
Nginx 配置文件分为两类:
|
Nginx 配置文件分为两类:
|
||||||
|
|
||||||
- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live/<SERVER_NAME>/fullchain.pem` 与 `privkey.pem` 已存在。
|
- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live/<SERVER_NAME>/fullchain.pem` 与 `privkey.pem` 已存在。`SERVER_NAME` 只填证书主目录名对应的单个域名;`www` 等额外域名通过 `SERVER_ALIASES` 写入 Nginx `server_name`,不参与证书目录拼接。
|
||||||
- `deploy/nginx/genarrative-dev-http.conf`:开发服无域名时的 HTTP-only 配置,只允许 `DEPLOY_TARGET=development` 使用。没有域名时,`SERVER_NAME` 填开发机 IP 或临时主机名。它仍复用同一套静态目录、后台 API 反代、临时主站 `/api/*` 反代和 SpacetimeDB SDK 最小公网路由,不恢复旧 `/generated-*` 或公网 `/healthz`。
|
- `deploy/nginx/genarrative-dev-http.conf`:开发服无域名时的 HTTP-only 配置,只允许 `DEPLOY_TARGET=development` 使用。没有域名时,`SERVER_NAME` 填开发机 IP 或临时主机名;如有多个入口,额外域名或 IP 填 `SERVER_ALIASES`。它仍复用同一套静态目录、后台 API 反代、临时主站 `/api/*` 反代和 SpacetimeDB SDK 最小公网路由,不恢复旧 `/generated-*` 或公网 `/healthz`。
|
||||||
|
|
||||||
## 维护模式
|
## 维护模式
|
||||||
|
|
||||||
@@ -273,12 +273,13 @@ journalctl -u 'jenkins-agent@*.service' -f
|
|||||||
Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆成两层配置:
|
Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆成两层配置:
|
||||||
|
|
||||||
- Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行,SCM URL 使用 controller 可访问的公网地址:`http://82.157.175.59:3000/GenarrativeAI/Genarrative.git`。
|
- Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行,SCM URL 使用 controller 可访问的公网地址:`http://82.157.175.59:3000/GenarrativeAI/Genarrative.git`。
|
||||||
- Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`。
|
- Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 优先使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`。
|
||||||
|
- 若 `127.0.0.1` Git 服务在当前 Linux agent 上不可达,发布、数据库和服务器配置类 Jenkinsfile 会用 `GIT_REMOTE_FALLBACK_URL=http://10.2.0.10:3000/GenarrativeAI/Genarrative.git` 重新 checkout;`scripts/jenkins-checkout-source.sh` 后续 fetch 也会按主地址、备用地址顺序重试,并在日志中输出最终使用的远端。`10.2.0.10` 是内网地址,Windows controller 或 Windows 构建节点不使用该 fallback。
|
||||||
- 这里的 `3000` 是 Git/Web 服务端口,不是 SpacetimeDB 端口;生产 SpacetimeDB 固定使用 `http://127.0.0.1:3101`,避免流水线部署时与本机 Git 服务抢端口。
|
- 这里的 `3000` 是 Git/Web 服务端口,不是 SpacetimeDB 端口;生产 SpacetimeDB 固定使用 `http://127.0.0.1:3101`,避免流水线部署时与本机 Git 服务抢端口。
|
||||||
|
|
||||||
因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], ...])`。后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为 `GIT_REMOTE_URL`,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。
|
因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: remoteUrl]], ...])`。首次 checkout 先尝试 `GIT_REMOTE_URL`,失败后尝试 `GIT_REMOTE_FALLBACK_URL`;后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为实际可用远端,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。
|
||||||
|
|
||||||
`127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,必须把对应 Jenkinsfile 的 `GIT_REMOTE_URL` 改成 release agent 可访问的内网地址,不能让 release 发布阶段回退到 controller 公网拉取。
|
`127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,应优先确认 `10.2.0.10` 这类内网备用地址是否可达,并按实际网络拓扑更新对应 Jenkinsfile 的 `GIT_REMOTE_FALLBACK_URL`。release 发布阶段不能回退到 controller 公网拉取。
|
||||||
|
|
||||||
### SSH PEM 凭证
|
### SSH PEM 凭证
|
||||||
|
|
||||||
@@ -462,7 +463,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
|
|||||||
该流水线属于高风险操作,默认要求人工确认后执行。
|
该流水线属于高风险操作,默认要求人工确认后执行。
|
||||||
已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、环境文件并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。
|
已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、环境文件并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。
|
||||||
|
|
||||||
首次真实初始化默认保持 `NGINX_CONFIG_MODE=none`,先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。开发服没有域名时,使用 `DEPLOY_TARGET=development` + `NGINX_CONFIG_MODE=development-http` 安装 `deploy/nginx/genarrative-dev-http.conf`,并把 `SERVER_NAME` 填为开发机 IP 或临时主机名。等正式域名确定,并且目标机已经存在 `/etc/letsencrypt/live/<SERVER_NAME>/fullchain.pem` 与 `/etc/letsencrypt/live/<SERVER_NAME>/privkey.pem` 后,再把 `SERVER_NAME` 改成真实域名,并设置 `NGINX_CONFIG_MODE=production-https` 安装 Nginx HTTPS 配置。流水线会拒绝 release 目标安装 `development-http`,也会拒绝用占位域名或缺失证书安装 `production-https`。Nginx 配置写入后必须先 `nginx -t`,再 `nginx -s reload`,不能只验证配置而不重载当前进程。
|
首次真实初始化默认保持 `NGINX_CONFIG_MODE=none`,先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。开发服没有域名时,使用 `DEPLOY_TARGET=development` + `NGINX_CONFIG_MODE=development-http` 安装 `deploy/nginx/genarrative-dev-http.conf`,并把 `SERVER_NAME` 填为开发机 IP 或临时主机名。等正式域名确定,并且目标机已经存在 `/etc/letsencrypt/live/<SERVER_NAME>/fullchain.pem` 与 `/etc/letsencrypt/live/<SERVER_NAME>/privkey.pem` 后,再把 `SERVER_NAME` 改成证书主域名,并设置 `NGINX_CONFIG_MODE=production-https` 安装 Nginx HTTPS 配置。如果同一张证书同时覆盖根域名和 `www` 域名,`SERVER_NAME` 仍只填证书目录名,例如 `genarrative.world`,`SERVER_ALIASES` 填 `www.genarrative.world`。流水线会拒绝 release 目标安装 `development-http`,也会拒绝用占位域名或缺失证书安装 `production-https`。Nginx 配置写入后必须先 `nginx -t`,再 `nginx -s reload`,不能只验证配置而不重载当前进程。
|
||||||
|
|
||||||
若误用占位域名执行过真实初始化,失败通常发生在 `nginx -t`,错误表现为找不到 `/etc/letsencrypt/live/genarrative.example.com/fullchain.pem` 或 `privkey.pem`。新版初始化在 `NGINX_CONFIG_MODE=none` 时会检测并禁用上一轮留下的占位域名 Nginx 配置,避免它继续影响后续 `nginx -t`。
|
若误用占位域名执行过真实初始化,失败通常发生在 `nginx -t`,错误表现为找不到 `/etc/letsencrypt/live/genarrative.example.com/fullchain.pem` 或 `privkey.pem`。新版初始化在 `NGINX_CONFIG_MODE=none` 时会检测并禁用上一轮留下的占位域名 Nginx 配置,避免它继续影响后续 `nginx -t`。
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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_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_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_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 边界、发送与校验职责、配置项和错误语义。
|
- [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_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_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_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_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_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` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。
|
- [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)
|
# 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 和兼容路由删除计划。
|
> 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` 条。
|
2. 内部鉴权调试接口:`2` 条。
|
||||||
3. AI task 接口:`9` 条。
|
3. AI task 接口:`9` 条。
|
||||||
4. assets / OSS 接口:`15` 条。
|
4. assets / OSS 接口:`15` 条。
|
||||||
5. auth 接口:`12` 条。
|
5. auth 接口:`13` 条。
|
||||||
6. custom world / agent 接口:`23` 条。
|
6. custom world / agent 接口:`23` 条。
|
||||||
7. match3d creation / runtime 接口:`14` 条。
|
7. match3d creation / runtime 接口:`14` 条。
|
||||||
8. llm proxy 接口:`1` 条。
|
8. llm proxy 接口:`1` 条。
|
||||||
@@ -84,13 +84,14 @@
|
|||||||
3. `POST /api/auth/logout`
|
3. `POST /api/auth/logout`
|
||||||
4. `POST /api/auth/logout-all`
|
4. `POST /api/auth/logout-all`
|
||||||
5. `GET /api/auth/sessions`
|
5. `GET /api/auth/sessions`
|
||||||
6. `POST /api/auth/refresh`
|
6. `POST /api/auth/sessions/{session_id}/revoke`
|
||||||
7. `POST /api/auth/phone/send-code`
|
7. `POST /api/auth/refresh`
|
||||||
8. `POST /api/auth/phone/login`
|
8. `POST /api/auth/phone/send-code`
|
||||||
9. `GET /api/auth/wechat/start`
|
9. `POST /api/auth/phone/login`
|
||||||
10. `GET /api/auth/wechat/callback`
|
10. `GET /api/auth/wechat/start`
|
||||||
11. `POST /api/auth/wechat/bind-phone`
|
11. `GET /api/auth/wechat/callback`
|
||||||
12. `POST /api/auth/entry`
|
12. `POST /api/auth/wechat/bind-phone`
|
||||||
|
13. `POST /api/auth/entry`
|
||||||
|
|
||||||
### 3.6 Custom World / Agent
|
### 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 |
|
| 管理兑换码 | `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 /_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/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 |
|
| 鉴权登录 | `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 |
|
| 旧本地生成资产代理 | `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 |
|
| 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/api.rs` | `ApiResponseMeta`、`ApiErrorPayload`、`ApiSuccessEnvelope<T>`、`ApiErrorEnvelope` |
|
||||||
| `shared-contracts/src/admin.rs` | `AdminLoginRequest/Response`、`AdminSessionPayload`、`AdminMeResponse`、`AdminOverviewResponse`、`AdminDebugHttpRequest/Response` |
|
| `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/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/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` |
|
| `shared-contracts/src/creation_agent_document_input.rs` | `ParseCreationAgentDocumentInputRequest/Response`、`CreationAgentDocumentInputPayload` |
|
||||||
|
|||||||
@@ -115,6 +115,8 @@
|
|||||||
1. 从 cookie 读出原始 refresh token
|
1. 从 cookie 读出原始 refresh token
|
||||||
2. 计算 hash
|
2. 计算 hash
|
||||||
3. 与 `refresh_session.refresh_token_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. 表访问级别
|
## 5. 表访问级别
|
||||||
|
|
||||||
@@ -228,9 +230,10 @@
|
|||||||
写入规则:
|
写入规则:
|
||||||
|
|
||||||
1. 按当前 cookie 找 session
|
1. 按当前 cookie 找 session
|
||||||
2. 写 `revoked_at = now`
|
2. 如果 refresh cookie 缺失,则回退用 Bearer access token claims 中的 `sid` 找当前 session
|
||||||
3. 写 `revoked_reason_code = logout`
|
3. 写 `revoked_at = now`
|
||||||
4. 同时提升 `user_account.token_version`
|
4. 写 `revoked_reason_code = logout`
|
||||||
|
5. 同时提升 `user_account.token_version`
|
||||||
|
|
||||||
### 8.4 吊销全部会话
|
### 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`
|
3. 只改目标 `refresh_session`
|
||||||
4. `revoked_reason_code = session_revoke`
|
4. `revoked_reason_code = session_revoke`
|
||||||
5. 不提升 `token_version`
|
5. 不提升 `token_version`
|
||||||
|
6. 撤销后必须同步 auth store 到 SpacetimeDB
|
||||||
|
|
||||||
|
读取约束:
|
||||||
|
|
||||||
|
1. Bearer JWT 中的 `sid` 必须对应 active `refresh_session`
|
||||||
|
2. 被该接口撤销的设备即使 access token 未过期,后续请求也必须立刻返回未授权
|
||||||
|
3. 该接口不承担当前设备退出语义;当前设备退出固定走 `/api/auth/logout`
|
||||||
|
|
||||||
### 8.6 账号被禁用或并入
|
### 8.6 账号被禁用或并入
|
||||||
|
|
||||||
@@ -315,13 +325,18 @@
|
|||||||
|
|
||||||
1. `clientLabel` 当前阶段继续兼容保留,但固定与 `deviceDisplayName` 对齐。
|
1. `clientLabel` 当前阶段继续兼容保留,但固定与 `deviceDisplayName` 对齐。
|
||||||
2. `ipMasked`、`isCurrent` 继续在 Axum 侧派生。
|
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`
|
### 10.3 `POST /api/auth/logout`
|
||||||
|
|
||||||
依赖:
|
依赖:
|
||||||
|
|
||||||
1. 当前 cookie 命中的 `refresh_session`
|
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`
|
### 10.4 `POST /api/auth/logout-all`
|
||||||
|
|
||||||
@@ -330,6 +345,22 @@
|
|||||||
1. 当前 `user_id` 下全部活跃 `refresh_session`
|
1. 当前 `user_id` 下全部活跃 `refresh_session`
|
||||||
2. `user_account.token_version`
|
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` 的映射关系
|
## 11. 与当前 Node `user_sessions` 的映射关系
|
||||||
|
|
||||||
| Node `user_sessions` 列 | 新 `refresh_session` 字段 | 迁移规则 |
|
| Node `user_sessions` 列 | 新 `refresh_session` 字段 | 迁移规则 |
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pipeline {
|
|||||||
|
|
||||||
environment {
|
environment {
|
||||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
||||||
|
GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git'
|
||||||
}
|
}
|
||||||
|
|
||||||
parameters {
|
parameters {
|
||||||
@@ -66,13 +67,25 @@ pipeline {
|
|||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
checkout([
|
script {
|
||||||
$class: 'GitSCM',
|
def checkoutFromRemote = { String remoteUrl ->
|
||||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
checkout([
|
||||||
doGenerateSubmoduleConfigurations: false,
|
$class: 'GitSCM',
|
||||||
extensions: [[$class: 'CleanBeforeCheckout']],
|
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
||||||
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
|
doGenerateSubmoduleConfigurations: false,
|
||||||
])
|
extensions: [[$class: 'CleanBeforeCheckout']],
|
||||||
|
userRemoteConfigs: [[url: remoteUrl]],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
|
||||||
|
} catch (error) {
|
||||||
|
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
|
||||||
|
}
|
||||||
|
}
|
||||||
script {
|
script {
|
||||||
if (params.COMMIT_HASH?.trim()) {
|
if (params.COMMIT_HASH?.trim()) {
|
||||||
echo "API 发布脚本 checkout 将忽略上游构建 commit=${params.COMMIT_HASH},改用 ${params.SOURCE_BRANCH ?: 'master'} 最新提交,避免发布阶段回退到旧部署脚本。构建产物仍由 BUILD_NUMBER_TO_DEPLOY 决定。"
|
echo "API 发布脚本 checkout 将忽略上游构建 commit=${params.COMMIT_HASH},改用 ${params.SOURCE_BRANCH ?: 'master'} 最新提交,避免发布阶段回退到旧部署脚本。构建产物仍由 BUILD_NUMBER_TO_DEPLOY 决定。"
|
||||||
@@ -84,7 +97,8 @@ pipeline {
|
|||||||
chmod +x scripts/jenkins-checkout-source.sh
|
chmod +x scripts/jenkins-checkout-source.sh
|
||||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
||||||
COMMIT_HASH="" \
|
COMMIT_HASH="" \
|
||||||
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
|
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
|
||||||
|
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
|
||||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
||||||
scripts/jenkins-checkout-source.sh
|
scripts/jenkins-checkout-source.sh
|
||||||
'
|
'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pipeline {
|
|||||||
|
|
||||||
environment {
|
environment {
|
||||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
||||||
|
GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git'
|
||||||
}
|
}
|
||||||
|
|
||||||
parameters {
|
parameters {
|
||||||
@@ -82,20 +83,33 @@ pipeline {
|
|||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
checkout([
|
script {
|
||||||
$class: 'GitSCM',
|
def checkoutFromRemote = { String remoteUrl ->
|
||||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
checkout([
|
||||||
doGenerateSubmoduleConfigurations: false,
|
$class: 'GitSCM',
|
||||||
extensions: [[$class: 'CleanBeforeCheckout']],
|
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
||||||
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
|
doGenerateSubmoduleConfigurations: false,
|
||||||
])
|
extensions: [[$class: 'CleanBeforeCheckout']],
|
||||||
|
userRemoteConfigs: [[url: remoteUrl]],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
|
||||||
|
} catch (error) {
|
||||||
|
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
|
||||||
|
}
|
||||||
|
}
|
||||||
sh '''
|
sh '''
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x scripts/jenkins-checkout-source.sh
|
chmod +x scripts/jenkins-checkout-source.sh
|
||||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
||||||
COMMIT_HASH="${COMMIT_HASH:-}" \
|
COMMIT_HASH="${COMMIT_HASH:-}" \
|
||||||
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
|
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
|
||||||
|
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
|
||||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
||||||
scripts/jenkins-checkout-source.sh
|
scripts/jenkins-checkout-source.sh
|
||||||
'
|
'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pipeline {
|
|||||||
|
|
||||||
environment {
|
environment {
|
||||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
||||||
|
GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git'
|
||||||
}
|
}
|
||||||
|
|
||||||
parameters {
|
parameters {
|
||||||
@@ -140,20 +141,33 @@ pipeline {
|
|||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
checkout([
|
script {
|
||||||
$class: 'GitSCM',
|
def checkoutFromRemote = { String remoteUrl ->
|
||||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
checkout([
|
||||||
doGenerateSubmoduleConfigurations: false,
|
$class: 'GitSCM',
|
||||||
extensions: [[$class: 'CleanBeforeCheckout']],
|
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
||||||
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
|
doGenerateSubmoduleConfigurations: false,
|
||||||
])
|
extensions: [[$class: 'CleanBeforeCheckout']],
|
||||||
|
userRemoteConfigs: [[url: remoteUrl]],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
|
||||||
|
} catch (error) {
|
||||||
|
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
|
||||||
|
}
|
||||||
|
}
|
||||||
sh '''
|
sh '''
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x scripts/jenkins-checkout-source.sh
|
chmod +x scripts/jenkins-checkout-source.sh
|
||||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
||||||
COMMIT_HASH="${COMMIT_HASH:-}" \
|
COMMIT_HASH="${COMMIT_HASH:-}" \
|
||||||
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
|
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
|
||||||
|
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
|
||||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
||||||
scripts/jenkins-checkout-source.sh
|
scripts/jenkins-checkout-source.sh
|
||||||
'
|
'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pipeline {
|
|||||||
|
|
||||||
environment {
|
environment {
|
||||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
||||||
|
GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git'
|
||||||
}
|
}
|
||||||
|
|
||||||
parameters {
|
parameters {
|
||||||
@@ -19,7 +20,8 @@ pipeline {
|
|||||||
booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置')
|
booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置')
|
||||||
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支')
|
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支')
|
||||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit')
|
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit')
|
||||||
string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: 'Nginx server_name 与证书域名')
|
string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: '证书主域名;也作为 Nginx server_name 的第一个域名')
|
||||||
|
string(name: 'SERVER_ALIASES', defaultValue: '', description: '可选,额外 Nginx server_name,多个用空格或逗号分隔,例如 www.genarrative.world')
|
||||||
string(name: 'SPACETIME_BIN_SOURCE', defaultValue: '/usr/local/bin/spacetime', description: '服务器上已有 spacetime CLI 路径')
|
string(name: 'SPACETIME_BIN_SOURCE', defaultValue: '/usr/local/bin/spacetime', description: '服务器上已有 spacetime CLI 路径')
|
||||||
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
|
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
|
||||||
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
|
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
|
||||||
@@ -47,6 +49,17 @@ pipeline {
|
|||||||
if (!params.SERVER_NAME?.trim()) {
|
if (!params.SERVER_NAME?.trim()) {
|
||||||
error('SERVER_NAME 不能为空。')
|
error('SERVER_NAME 不能为空。')
|
||||||
}
|
}
|
||||||
|
if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
|
||||||
|
error("SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${params.SERVER_NAME}")
|
||||||
|
}
|
||||||
|
def serverAliases = params.SERVER_ALIASES?.trim()
|
||||||
|
if (serverAliases) {
|
||||||
|
serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName ->
|
||||||
|
if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) {
|
||||||
|
error("SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${aliasName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!params.SPACETIME_BIN_SOURCE?.trim()) {
|
if (!params.SPACETIME_BIN_SOURCE?.trim()) {
|
||||||
error('SPACETIME_BIN_SOURCE 不能为空。')
|
error('SPACETIME_BIN_SOURCE 不能为空。')
|
||||||
}
|
}
|
||||||
@@ -69,20 +82,33 @@ pipeline {
|
|||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
checkout([
|
script {
|
||||||
$class: 'GitSCM',
|
def checkoutFromRemote = { String remoteUrl ->
|
||||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
checkout([
|
||||||
doGenerateSubmoduleConfigurations: false,
|
$class: 'GitSCM',
|
||||||
extensions: [[$class: 'CleanBeforeCheckout']],
|
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
||||||
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
|
doGenerateSubmoduleConfigurations: false,
|
||||||
])
|
extensions: [[$class: 'CleanBeforeCheckout']],
|
||||||
|
userRemoteConfigs: [[url: remoteUrl]],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
|
||||||
|
} catch (error) {
|
||||||
|
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
|
||||||
|
}
|
||||||
|
}
|
||||||
sh '''
|
sh '''
|
||||||
bash <<'BASH'
|
bash <<'BASH'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x scripts/jenkins-checkout-source.sh
|
chmod +x scripts/jenkins-checkout-source.sh
|
||||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
||||||
COMMIT_HASH="${COMMIT_HASH:-}" \
|
COMMIT_HASH="${COMMIT_HASH:-}" \
|
||||||
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
|
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
|
||||||
|
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
|
||||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
||||||
scripts/jenkins-checkout-source.sh
|
scripts/jenkins-checkout-source.sh
|
||||||
BASH
|
BASH
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pipeline {
|
|||||||
|
|
||||||
environment {
|
environment {
|
||||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
||||||
|
GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git'
|
||||||
}
|
}
|
||||||
|
|
||||||
parameters {
|
parameters {
|
||||||
@@ -78,20 +79,33 @@ pipeline {
|
|||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
checkout([
|
script {
|
||||||
$class: 'GitSCM',
|
def checkoutFromRemote = { String remoteUrl ->
|
||||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
checkout([
|
||||||
doGenerateSubmoduleConfigurations: false,
|
$class: 'GitSCM',
|
||||||
extensions: [[$class: 'CleanBeforeCheckout']],
|
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
||||||
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
|
doGenerateSubmoduleConfigurations: false,
|
||||||
])
|
extensions: [[$class: 'CleanBeforeCheckout']],
|
||||||
|
userRemoteConfigs: [[url: remoteUrl]],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
|
||||||
|
} catch (error) {
|
||||||
|
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
|
||||||
|
}
|
||||||
|
}
|
||||||
sh '''
|
sh '''
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x scripts/jenkins-checkout-source.sh
|
chmod +x scripts/jenkins-checkout-source.sh
|
||||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
||||||
COMMIT_HASH="${COMMIT_HASH:-}" \
|
COMMIT_HASH="${COMMIT_HASH:-}" \
|
||||||
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
|
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
|
||||||
|
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
|
||||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
||||||
scripts/jenkins-checkout-source.sh
|
scripts/jenkins-checkout-source.sh
|
||||||
'
|
'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pipeline {
|
|||||||
|
|
||||||
environment {
|
environment {
|
||||||
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
|
||||||
|
GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git'
|
||||||
WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts'
|
WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,20 +55,33 @@ pipeline {
|
|||||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
checkout([
|
script {
|
||||||
$class: 'GitSCM',
|
def checkoutFromRemote = { String remoteUrl ->
|
||||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
checkout([
|
||||||
doGenerateSubmoduleConfigurations: false,
|
$class: 'GitSCM',
|
||||||
extensions: [[$class: 'CleanBeforeCheckout']],
|
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
||||||
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
|
doGenerateSubmoduleConfigurations: false,
|
||||||
])
|
extensions: [[$class: 'CleanBeforeCheckout']],
|
||||||
|
userRemoteConfigs: [[url: remoteUrl]],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
|
||||||
|
} catch (error) {
|
||||||
|
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
|
||||||
|
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
|
||||||
|
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
|
||||||
|
}
|
||||||
|
}
|
||||||
sh '''
|
sh '''
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x scripts/jenkins-checkout-source.sh
|
chmod +x scripts/jenkins-checkout-source.sh
|
||||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
||||||
COMMIT_HASH="${COMMIT_HASH:-}" \
|
COMMIT_HASH="${COMMIT_HASH:-}" \
|
||||||
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
|
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
|
||||||
|
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
|
||||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
||||||
scripts/jenkins-checkout-source.sh
|
scripts/jenkins-checkout-source.sh
|
||||||
'
|
'
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ export type AuthRefreshResponse = {
|
|||||||
|
|
||||||
export type AuthSessionSummary = {
|
export type AuthSessionSummary = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
sessionIds: string[];
|
||||||
|
sessionCount: number;
|
||||||
clientType: string;
|
clientType: string;
|
||||||
clientRuntime: string;
|
clientRuntime: string;
|
||||||
clientPlatform: string;
|
clientPlatform: string;
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ set -euo pipefail
|
|||||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}"
|
SOURCE_BRANCH="${SOURCE_BRANCH:-master}"
|
||||||
COMMIT_HASH="${COMMIT_HASH:-}"
|
COMMIT_HASH="${COMMIT_HASH:-}"
|
||||||
GIT_REMOTE_URL="${GIT_REMOTE_URL:-}"
|
GIT_REMOTE_URL="${GIT_REMOTE_URL:-}"
|
||||||
|
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}"
|
||||||
SOURCE_COMMIT_FILE="${SOURCE_COMMIT_FILE:-.jenkins-source-commit}"
|
SOURCE_COMMIT_FILE="${SOURCE_COMMIT_FILE:-.jenkins-source-commit}"
|
||||||
|
|
||||||
# Windows PowerShell 5.1 的 UTF-8 输出可能带 BOM;下游参数校验前先剥离不可见字节。
|
# Windows PowerShell 5.1 的 UTF-8 输出可能带 BOM;下游参数校验前先剥离不可见字节。
|
||||||
SOURCE_BRANCH="$(printf "%s" "${SOURCE_BRANCH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
|
SOURCE_BRANCH="$(printf "%s" "${SOURCE_BRANCH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
|
||||||
COMMIT_HASH="$(printf "%s" "${COMMIT_HASH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
|
COMMIT_HASH="$(printf "%s" "${COMMIT_HASH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
|
||||||
|
GIT_REMOTE_URL="$(printf "%s" "${GIT_REMOTE_URL}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
|
||||||
|
GIT_REMOTE_FALLBACK_URL="$(printf "%s" "${GIT_REMOTE_FALLBACK_URL}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')"
|
||||||
|
|
||||||
if [[ ! "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]]; then
|
if [[ ! "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]]; then
|
||||||
echo "[jenkins-checkout-source] SOURCE_BRANCH 只能包含数字、字母、点、下划线、短横线和斜杠: ${SOURCE_BRANCH}" >&2
|
echo "[jenkins-checkout-source] SOURCE_BRANCH 只能包含数字、字母、点、下划线、短横线和斜杠: ${SOURCE_BRANCH}" >&2
|
||||||
@@ -26,12 +29,52 @@ if [[ -n "${COMMIT_HASH}" && ! "${COMMIT_HASH}" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${GIT_REMOTE_URL}" ]]; then
|
GIT_REMOTE_CANDIDATES=()
|
||||||
git remote set-url origin "${GIT_REMOTE_URL}"
|
add_git_remote_candidate() {
|
||||||
fi
|
local candidate="$1"
|
||||||
|
local existing
|
||||||
|
if [[ -z "${candidate}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
for existing in "${GIT_REMOTE_CANDIDATES[@]}"; do
|
||||||
|
if [[ "${existing}" == "${candidate}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
GIT_REMOTE_CANDIDATES+=("${candidate}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_source_branch() {
|
||||||
|
local remote_url="$1"
|
||||||
|
if [[ -n "${remote_url}" ]]; then
|
||||||
|
git remote set-url origin "${remote_url}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[jenkins-checkout-source] 尝试 Git 远端: ${remote_url:-origin}"
|
||||||
|
git fetch --tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}"
|
||||||
|
}
|
||||||
|
|
||||||
|
add_git_remote_candidate "${GIT_REMOTE_URL}"
|
||||||
|
add_git_remote_candidate "${GIT_REMOTE_FALLBACK_URL}"
|
||||||
|
|
||||||
git reset --hard HEAD
|
git reset --hard HEAD
|
||||||
git fetch --tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}"
|
if [[ "${#GIT_REMOTE_CANDIDATES[@]}" -eq 0 ]]; then
|
||||||
|
fetch_source_branch ""
|
||||||
|
else
|
||||||
|
fetch_ok=0
|
||||||
|
for git_remote_candidate in "${GIT_REMOTE_CANDIDATES[@]}"; do
|
||||||
|
if fetch_source_branch "${git_remote_candidate}"; then
|
||||||
|
GIT_REMOTE_URL="${git_remote_candidate}"
|
||||||
|
fetch_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "[jenkins-checkout-source] Git 远端拉取失败: ${git_remote_candidate}" >&2
|
||||||
|
done
|
||||||
|
if [[ "${fetch_ok}" -ne 1 ]]; then
|
||||||
|
echo "[jenkins-checkout-source] 所有 Git 远端均拉取失败。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then
|
if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then
|
||||||
git fetch --unshallow --tags || true
|
git fetch --unshallow --tags || true
|
||||||
@@ -55,4 +98,4 @@ git reset --hard HEAD
|
|||||||
git clean -fd
|
git clean -fd
|
||||||
|
|
||||||
printf "%s\n" "${RESOLVED_COMMIT}" >"${SOURCE_COMMIT_FILE}"
|
printf "%s\n" "${RESOLVED_COMMIT}" >"${SOURCE_COMMIT_FILE}"
|
||||||
echo "[jenkins-checkout-source] 使用源码: branch=${SOURCE_BRANCH} commit=${RESOLVED_COMMIT}"
|
echo "[jenkins-checkout-source] 使用源码: branch=${SOURCE_BRANCH} commit=${RESOLVED_COMMIT} remote=${GIT_REMOTE_URL:-origin}"
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ require_path() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalize_server_aliases() {
|
||||||
|
printf "%s" "${SERVER_ALIASES:-}" | tr ',' ' ' | xargs
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_server_names() {
|
||||||
|
local alias_name
|
||||||
|
if [[ -z "${SERVER_NAME:-}" ]]; then
|
||||||
|
echo "[server-provision] SERVER_NAME 不能为空。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! "${SERVER_NAME}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]; then
|
||||||
|
echo "[server-provision] SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${SERVER_NAME}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for alias_name in $(normalize_server_aliases); do
|
||||||
|
if [[ ! "${alias_name}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]; then
|
||||||
|
echo "[server-provision] SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${alias_name}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
run_cmd() {
|
run_cmd() {
|
||||||
echo "+ $*"
|
echo "+ $*"
|
||||||
if [[ "${DRY_RUN}" != "true" ]]; then
|
if [[ "${DRY_RUN}" != "true" ]]; then
|
||||||
@@ -336,10 +358,15 @@ EOF
|
|||||||
|
|
||||||
render_nginx_template() {
|
render_nginx_template() {
|
||||||
local template="$1"
|
local template="$1"
|
||||||
local rendered_brotli
|
local rendered_brotli server_names
|
||||||
rendered_brotli="$(render_nginx_brotli_directives)"
|
rendered_brotli="$(render_nginx_brotli_directives)"
|
||||||
|
server_names="${SERVER_NAME}"
|
||||||
|
if [[ -n "${SERVER_ALIASES:-}" ]]; then
|
||||||
|
server_names="${server_names} $(normalize_server_aliases)"
|
||||||
|
fi
|
||||||
sed \
|
sed \
|
||||||
-e "s/genarrative.example.com/${SERVER_NAME}/g" \
|
-e "s/server_name genarrative.example.com;/server_name ${server_names};/g" \
|
||||||
|
-e "s|/etc/letsencrypt/live/genarrative.example.com/|/etc/letsencrypt/live/${SERVER_NAME}/|g" \
|
||||||
-e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/r /dev/stdin" \
|
-e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/r /dev/stdin" \
|
||||||
-e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/d" \
|
-e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/d" \
|
||||||
"${template}" <<<"${rendered_brotli}"
|
"${template}" <<<"${rendered_brotli}"
|
||||||
@@ -504,6 +531,8 @@ require_path scripts/deploy/maintenance-on.sh
|
|||||||
require_path scripts/deploy/maintenance-off.sh
|
require_path scripts/deploy/maintenance-off.sh
|
||||||
require_path scripts/deploy/maintenance-status.sh
|
require_path scripts/deploy/maintenance-status.sh
|
||||||
|
|
||||||
|
validate_server_names
|
||||||
|
|
||||||
echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)"
|
echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)"
|
||||||
|
|
||||||
run_cmd id
|
run_cmd id
|
||||||
|
|||||||
@@ -776,7 +776,8 @@ mod tests {
|
|||||||
let claims = AccessTokenClaims::from_input(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: "user_00000001".to_string(),
|
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,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: 2,
|
token_version: 2,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
auth_me::auth_me,
|
auth_me::auth_me,
|
||||||
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
|
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
|
||||||
auth_sessions::auth_sessions,
|
auth_sessions::{auth_sessions, revoke_auth_session},
|
||||||
big_fish::{
|
big_fish::{
|
||||||
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
|
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
|
||||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
|
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
|
||||||
@@ -331,6 +331,13 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_bearer_auth,
|
require_bearer_auth,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/auth/sessions/{session_id}/revoke",
|
||||||
|
post(revoke_auth_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/profile/me",
|
"/api/profile/me",
|
||||||
axum::routing::patch(update_profile_identity).route_layer(
|
axum::routing::patch(update_profile_identity).route_layer(
|
||||||
@@ -1921,10 +1928,12 @@ mod tests {
|
|||||||
user: &module_auth::AuthUser,
|
user: &module_auth::AuthUser,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let active_session_id = state.seed_test_refresh_session_for_user(user, session_id);
|
||||||
let claims = AccessTokenClaims::from_input(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: user.id.clone(),
|
user_id: user.id.clone(),
|
||||||
session_id: session_id.to_string(),
|
session_id: active_session_id,
|
||||||
provider: AuthProvider::Password,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: user.token_version,
|
token_version: user.token_version,
|
||||||
@@ -1933,13 +1942,22 @@ mod tests {
|
|||||||
display_name: Some(user.display_name.clone()),
|
display_name: Some(user.display_name.clone()),
|
||||||
},
|
},
|
||||||
state.auth_jwt_config(),
|
state.auth_jwt_config(),
|
||||||
OffsetDateTime::now_utc(),
|
now,
|
||||||
)
|
)
|
||||||
.expect("claims should build");
|
.expect("claims should build");
|
||||||
|
|
||||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_access_token(response_body: &[u8]) -> String {
|
||||||
|
let payload: Value =
|
||||||
|
serde_json::from_slice(response_body).expect("login payload should be json");
|
||||||
|
payload["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("access token should exist")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
async fn password_login_request(
|
async fn password_login_request(
|
||||||
app: Router,
|
app: Router,
|
||||||
phone_number: &str,
|
phone_number: &str,
|
||||||
@@ -1963,6 +1981,37 @@ mod tests {
|
|||||||
.expect("password login request should succeed")
|
.expect("password login request should succeed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn password_login_request_with_client(
|
||||||
|
app: Router,
|
||||||
|
phone_number: &str,
|
||||||
|
password: &str,
|
||||||
|
client_instance_id: &str,
|
||||||
|
forwarded_for: &str,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
app.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", client_instance_id)
|
||||||
|
.header("x-forwarded-for", forwarded_for)
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::json!({
|
||||||
|
"phone": phone_number,
|
||||||
|
"password": password
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("password login request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("password login request should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
fn build_internal_creative_agent_app() -> Router {
|
fn build_internal_creative_agent_app() -> Router {
|
||||||
let mut config = AppConfig::default();
|
let mut config = AppConfig::default();
|
||||||
config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string());
|
config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string());
|
||||||
@@ -2536,10 +2585,11 @@ mod tests {
|
|||||||
let config = AppConfig::default();
|
let config = AppConfig::default();
|
||||||
let state = AppState::new(config.clone()).expect("state should build");
|
let state = AppState::new(config.clone()).expect("state should build");
|
||||||
let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await;
|
let seed_user = seed_phone_user_with_password(&state, "13800138010", TEST_PASSWORD).await;
|
||||||
|
let session_id = state.seed_test_refresh_session_for_user(&seed_user, "sess_auth_debug");
|
||||||
let claims = AccessTokenClaims::from_input(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: seed_user.id.clone(),
|
user_id: seed_user.id.clone(),
|
||||||
session_id: "sess_auth_debug".to_string(),
|
session_id: session_id.clone(),
|
||||||
provider: AuthProvider::Password,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: seed_user.token_version,
|
token_version: seed_user.token_version,
|
||||||
@@ -2577,10 +2627,7 @@ mod tests {
|
|||||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||||
|
|
||||||
assert_eq!(payload["claims"]["sub"], Value::String(seed_user.id));
|
assert_eq!(payload["claims"]["sub"], Value::String(seed_user.id));
|
||||||
assert_eq!(
|
assert_eq!(payload["claims"]["sid"], Value::String(session_id));
|
||||||
payload["claims"]["sid"],
|
|
||||||
Value::String("sess_auth_debug".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["claims"]["ver"],
|
payload["claims"]["ver"],
|
||||||
Value::Number(serde_json::Number::from(seed_user.token_version))
|
Value::Number(serde_json::Number::from(seed_user.token_version))
|
||||||
@@ -4238,12 +4285,17 @@ mod tests {
|
|||||||
session["clientType"] == Value::String("web_browser".to_string())
|
session["clientType"] == Value::String("web_browser".to_string())
|
||||||
&& session["clientRuntime"] == Value::String("chrome".to_string())
|
&& session["clientRuntime"] == Value::String("chrome".to_string())
|
||||||
&& session["clientPlatform"] == Value::String("windows".to_string())
|
&& session["clientPlatform"] == Value::String("windows".to_string())
|
||||||
|
&& session["sessionCount"] == Value::Number(1.into())
|
||||||
|
&& session["sessionIds"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|ids| ids.len() == 1)
|
||||||
&& session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string())
|
&& session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string())
|
||||||
&& session["isCurrent"] == Value::Bool(true)
|
&& session["isCurrent"] == Value::Bool(true)
|
||||||
}));
|
}));
|
||||||
assert!(sessions.iter().any(|session| {
|
assert!(sessions.iter().any(|session| {
|
||||||
session["clientType"] == Value::String("mini_program".to_string())
|
session["clientType"] == Value::String("mini_program".to_string())
|
||||||
&& session["clientRuntime"] == Value::String("wechat_mini_program".to_string())
|
&& session["clientRuntime"] == Value::String("wechat_mini_program".to_string())
|
||||||
|
&& session["sessionCount"] == Value::Number(1.into())
|
||||||
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string())
|
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string())
|
||||||
&& session["miniProgramEnv"] == Value::String("release".to_string())
|
&& session["miniProgramEnv"] == Value::String("release".to_string())
|
||||||
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string())
|
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string())
|
||||||
@@ -4251,6 +4303,108 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auth_sessions_groups_same_device_same_ip_and_marks_current_group() {
|
||||||
|
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||||
|
seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await;
|
||||||
|
let app = build_router(state);
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"phone": "13800138028",
|
||||||
|
"password": TEST_PASSWORD
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
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", "same-device")
|
||||||
|
.header("x-forwarded-for", "203.0.113.10")
|
||||||
|
.body(Body::from(login_body.clone()))
|
||||||
|
.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 access_token = read_access_token(&first_body);
|
||||||
|
|
||||||
|
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", "same-device")
|
||||||
|
.header("x-forwarded-for", "203.0.113.10")
|
||||||
|
.body(Body::from(login_body))
|
||||||
|
.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(), 1);
|
||||||
|
assert_eq!(sessions[0]["sessionCount"], Value::Number(2.into()));
|
||||||
|
assert_eq!(sessions[0]["isCurrent"], Value::Bool(true));
|
||||||
|
assert_eq!(
|
||||||
|
sessions[0]["ipMasked"],
|
||||||
|
Value::String("203.0.*.*".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sessions[0]["sessionIds"]
|
||||||
|
.as_array()
|
||||||
|
.expect("session ids should exist")
|
||||||
|
.len(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn password_entry_reuses_same_user_for_same_phone() {
|
async fn password_entry_reuses_same_user_for_same_phone() {
|
||||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||||
@@ -4362,9 +4516,23 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn password_change_allows_login_with_new_password_only() {
|
async fn password_change_allows_login_with_new_password_only() {
|
||||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||||
let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
|
seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
|
||||||
let token = sign_test_user_token(&state, &seed_user, "sess_password_change");
|
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
|
let login_response =
|
||||||
|
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
|
||||||
|
let refresh_cookie = login_response
|
||||||
|
.headers()
|
||||||
|
.get("set-cookie")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.expect("refresh cookie should exist")
|
||||||
|
.to_string();
|
||||||
|
let login_body = login_response
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("login body should collect")
|
||||||
|
.to_bytes();
|
||||||
|
let token = read_access_token(&login_body);
|
||||||
|
|
||||||
let change_response = app
|
let change_response = app
|
||||||
.clone()
|
.clone()
|
||||||
@@ -4386,6 +4554,40 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("change password request should succeed");
|
.expect("change password request should succeed");
|
||||||
assert_eq!(change_response.status(), StatusCode::OK);
|
assert_eq!(change_response.status(), StatusCode::OK);
|
||||||
|
assert!(
|
||||||
|
change_response
|
||||||
|
.headers()
|
||||||
|
.get("set-cookie")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let old_me_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/auth/me")
|
||||||
|
.header("authorization", format!("Bearer {token}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("me request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("me request should succeed");
|
||||||
|
assert_eq!(old_me_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let old_refresh_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/auth/refresh")
|
||||||
|
.header("cookie", refresh_cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("refresh request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("refresh request should succeed");
|
||||||
|
assert_eq!(old_refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
let old_password_response =
|
let old_password_response =
|
||||||
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
|
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
|
||||||
@@ -4429,23 +4631,16 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let state = AppState::new(config).expect("state should build");
|
let state = AppState::new(config).expect("state should build");
|
||||||
let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await;
|
let seed_user = seed_phone_user_with_password(&state, "13800138016", TEST_PASSWORD).await;
|
||||||
let claims = AccessTokenClaims::from_input(
|
|
||||||
AccessTokenClaimsInput {
|
|
||||||
user_id: seed_user.id.clone(),
|
|
||||||
session_id: "sess_me_query".to_string(),
|
|
||||||
provider: AuthProvider::Password,
|
|
||||||
roles: vec!["user".to_string()],
|
|
||||||
token_version: seed_user.token_version,
|
|
||||||
phone_verified: false,
|
|
||||||
binding_status: BindingStatus::Active,
|
|
||||||
display_name: Some(seed_user.display_name.clone()),
|
|
||||||
},
|
|
||||||
state.auth_jwt_config(),
|
|
||||||
OffsetDateTime::now_utc(),
|
|
||||||
)
|
|
||||||
.expect("claims should build");
|
|
||||||
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
|
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
|
let login_response =
|
||||||
|
password_login_request(app.clone(), "13800138016", TEST_PASSWORD).await;
|
||||||
|
let login_body = login_response
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("login body should collect")
|
||||||
|
.to_bytes();
|
||||||
|
let token = read_access_token(&login_body);
|
||||||
|
|
||||||
let response = app
|
let response = app
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -4606,6 +4801,141 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn revoke_auth_session_revokes_remote_session_without_token_version_bump() {
|
||||||
|
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||||
|
seed_phone_user_with_password(&state, "13800138030", TEST_PASSWORD).await;
|
||||||
|
let app = build_router(state);
|
||||||
|
|
||||||
|
let first_login_response = password_login_request_with_client(
|
||||||
|
app.clone(),
|
||||||
|
"13800138030",
|
||||||
|
TEST_PASSWORD,
|
||||||
|
"revoke-current-device",
|
||||||
|
"203.0.113.30",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
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_access_token = read_access_token(&first_body);
|
||||||
|
|
||||||
|
let second_login_response = password_login_request_with_client(
|
||||||
|
app.clone(),
|
||||||
|
"13800138030",
|
||||||
|
TEST_PASSWORD,
|
||||||
|
"revoke-remote-device",
|
||||||
|
"203.0.113.31",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let second_cookie = second_login_response
|
||||||
|
.headers()
|
||||||
|
.get("set-cookie")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.expect("second cookie should exist")
|
||||||
|
.to_string();
|
||||||
|
let second_body = second_login_response
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("second login body should collect")
|
||||||
|
.to_bytes();
|
||||||
|
let second_access_token = read_access_token(&second_body);
|
||||||
|
|
||||||
|
let remote_sessions_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/auth/sessions")
|
||||||
|
.header("authorization", format!("Bearer {first_access_token}"))
|
||||||
|
.header("cookie", first_cookie.clone())
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("sessions request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("sessions request should succeed");
|
||||||
|
assert_eq!(remote_sessions_response.status(), StatusCode::OK);
|
||||||
|
let remote_sessions_body = remote_sessions_response
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("sessions body should collect")
|
||||||
|
.to_bytes();
|
||||||
|
let remote_sessions_payload: Value =
|
||||||
|
serde_json::from_slice(&remote_sessions_body).expect("sessions payload should be json");
|
||||||
|
let remote_session_id = remote_sessions_payload["sessions"]
|
||||||
|
.as_array()
|
||||||
|
.expect("sessions should be array")
|
||||||
|
.iter()
|
||||||
|
.find(|session| session["isCurrent"] == Value::Bool(false))
|
||||||
|
.and_then(|session| session["sessionId"].as_str())
|
||||||
|
.expect("remote session id should exist")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let revoke_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("/api/auth/sessions/{remote_session_id}/revoke"))
|
||||||
|
.header("authorization", format!("Bearer {first_access_token}"))
|
||||||
|
.header("cookie", first_cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("revoke request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("revoke request should succeed");
|
||||||
|
assert_eq!(revoke_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let current_me_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/auth/me")
|
||||||
|
.header("authorization", format!("Bearer {first_access_token}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("current me request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("current me request should succeed");
|
||||||
|
assert_eq!(current_me_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let remote_me_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/auth/me")
|
||||||
|
.header("authorization", format!("Bearer {second_access_token}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("remote me request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("remote me request should succeed");
|
||||||
|
assert_eq!(remote_me_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let remote_refresh_response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/auth/refresh")
|
||||||
|
.header("cookie", second_cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("remote refresh request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("remote refresh request should succeed");
|
||||||
|
assert_eq!(remote_refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn logout_clears_cookie_and_invalidates_current_access_token() {
|
async fn logout_clears_cookie_and_invalidates_current_access_token() {
|
||||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||||
@@ -4688,6 +5018,12 @@ mod tests {
|
|||||||
|
|
||||||
let login_response =
|
let login_response =
|
||||||
password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await;
|
password_login_request(app.clone(), "13800138019", TEST_PASSWORD).await;
|
||||||
|
let refresh_cookie = login_response
|
||||||
|
.headers()
|
||||||
|
.get("set-cookie")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.expect("refresh cookie should exist")
|
||||||
|
.to_string();
|
||||||
let login_body = login_response
|
let login_body = login_response
|
||||||
.into_body()
|
.into_body()
|
||||||
.collect()
|
.collect()
|
||||||
@@ -4702,6 +5038,7 @@ mod tests {
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let logout_response = app
|
let logout_response = app
|
||||||
|
.clone()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method("POST")
|
.method("POST")
|
||||||
@@ -4721,6 +5058,19 @@ mod tests {
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let refresh_response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/auth/refresh")
|
||||||
|
.header("cookie", refresh_cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("refresh request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("refresh request should succeed");
|
||||||
|
assert_eq!(refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -117,6 +117,34 @@ pub async fn require_bearer_auth(
|
|||||||
.with_message("当前登录态已失效,请重新登录"));
|
.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
|
request
|
||||||
.extensions_mut()
|
.extensions_mut()
|
||||||
.insert(AuthenticatedAccessToken::new(claims.clone()));
|
.insert(AuthenticatedAccessToken::new(claims.clone()));
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Extension, State},
|
extract::{Extension, Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use module_auth::{RefreshSessionRecord, RevokeRefreshSessionByUserInput};
|
||||||
use platform_auth::hash_refresh_session_token;
|
use platform_auth::hash_refresh_session_token;
|
||||||
use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse};
|
use shared_contracts::auth::{
|
||||||
|
AuthSessionSummaryPayload, AuthSessionsResponse, RevokeAuthSessionResponse,
|
||||||
|
};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -37,41 +42,189 @@ pub async fn auth_sessions(
|
|||||||
.refresh_session_service()
|
.refresh_session_service()
|
||||||
.list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc())
|
.list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc())
|
||||||
.map_err(map_refresh_session_list_error)?;
|
.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(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
AuthSessionsResponse {
|
AuthSessionsResponse {
|
||||||
sessions: sessions
|
sessions: session_groups
|
||||||
.sessions
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|session| {
|
.map(|group| {
|
||||||
let is_current = current_refresh_token_hash
|
build_session_summary(
|
||||||
.as_ref()
|
group,
|
||||||
.is_some_and(|hash| session.refresh_token_hash == *hash);
|
current_refresh_token_hash.as_deref(),
|
||||||
let client_label = session.client_info.device_display_name.clone();
|
¤t_session_id,
|
||||||
|
)
|
||||||
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(),
|
.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 {
|
fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError {
|
||||||
match error {
|
match error {
|
||||||
module_auth::RefreshSessionError::UserNotFound => {
|
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(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: user.id,
|
user_id: user.id.clone(),
|
||||||
session_id: "sess_creation_doc_input".to_string(),
|
session_id: state
|
||||||
|
.seed_test_refresh_session_for_user(&user, "sess_creation_doc_input"),
|
||||||
provider: AuthProvider::Password,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: user.token_version,
|
token_version: user.token_version,
|
||||||
phone_verified: true,
|
phone_verified: true,
|
||||||
binding_status: BindingStatus::Active,
|
binding_status: BindingStatus::Active,
|
||||||
display_name: Some(user.display_name),
|
display_name: Some(user.display_name.clone()),
|
||||||
},
|
},
|
||||||
state.auth_jwt_config(),
|
state.auth_jwt_config(),
|
||||||
OffsetDateTime::now_utc(),
|
OffsetDateTime::now_utc(),
|
||||||
|
|||||||
@@ -333,7 +333,8 @@ mod tests {
|
|||||||
let claims = AccessTokenClaims::from_input(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: "user_00000001".to_string(),
|
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,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: 2,
|
token_version: 2,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub async fn logout(
|
|||||||
LogoutCurrentSessionInput {
|
LogoutCurrentSessionInput {
|
||||||
user_id: authenticated.claims().user_id().to_string(),
|
user_id: authenticated.claims().user_id().to_string(),
|
||||||
refresh_token_hash,
|
refresh_token_hash,
|
||||||
|
session_id: Some(authenticated.claims().session_id().to_string()),
|
||||||
},
|
},
|
||||||
OffsetDateTime::now_utc(),
|
OffsetDateTime::now_utc(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ use crate::{
|
|||||||
auth::AuthenticatedAccessToken,
|
auth::AuthenticatedAccessToken,
|
||||||
auth_payload::map_auth_user_payload,
|
auth_payload::map_auth_user_payload,
|
||||||
auth_session::{
|
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,
|
record_daily_login_tracking_event_after_auth_success,
|
||||||
},
|
},
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
@@ -30,14 +31,17 @@ pub async fn change_password(
|
|||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
Json(payload): Json<PasswordChangeRequest>,
|
Json(payload): Json<PasswordChangeRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let result = state
|
let result = state
|
||||||
.password_entry_service()
|
.password_entry_service()
|
||||||
.change_password(ChangePasswordInput {
|
.change_password_and_revoke_all_sessions(
|
||||||
user_id: authenticated.claims().user_id().to_string(),
|
ChangePasswordInput {
|
||||||
current_password: payload.current_password,
|
user_id: authenticated.claims().user_id().to_string(),
|
||||||
new_password: payload.new_password,
|
current_password: payload.current_password,
|
||||||
})
|
new_password: payload.new_password,
|
||||||
|
},
|
||||||
|
OffsetDateTime::now_utc(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(map_password_management_error)?;
|
.map_err(map_password_management_error)?;
|
||||||
state
|
state
|
||||||
@@ -48,11 +52,20 @@ pub async fn change_password(
|
|||||||
.with_message(format!("同步认证快照失败:{error}"))
|
.with_message(format!("同步认证快照失败:{error}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(json_success_body(
|
let mut headers = HeaderMap::new();
|
||||||
Some(&request_context),
|
attach_set_cookie_header(
|
||||||
PasswordChangeResponse {
|
&mut headers,
|
||||||
user: map_auth_user_payload(result.user),
|
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(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: "user_00000001".to_string(),
|
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,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: 2,
|
token_version: 2,
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ mod tests {
|
|||||||
let claims = AccessTokenClaims::from_input(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: "user_00000001".to_string(),
|
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,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: 2,
|
token_version: 2,
|
||||||
|
|||||||
@@ -1568,7 +1568,8 @@ mod tests {
|
|||||||
let claims = AccessTokenClaims::from_input(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: "user_00000001".to_string(),
|
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,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: 2,
|
token_version: 2,
|
||||||
|
|||||||
@@ -575,7 +575,8 @@ mod tests {
|
|||||||
let claims = AccessTokenClaims::from_input(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: "user_00000001".to_string(),
|
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,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: 2,
|
token_version: 2,
|
||||||
|
|||||||
@@ -350,7 +350,10 @@ mod tests {
|
|||||||
let claims = AccessTokenClaims::from_input(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: "user_00000001".to_string(),
|
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,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: 2,
|
token_version: 2,
|
||||||
|
|||||||
@@ -600,6 +600,54 @@ impl AppState {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
impl AppState {
|
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) {
|
fn cache_test_creation_entry_config(&self, config: CreationEntryConfigResponse) {
|
||||||
*self
|
*self
|
||||||
.test_creation_entry_config
|
.test_creation_entry_config
|
||||||
|
|||||||
@@ -959,7 +959,8 @@ mod tests {
|
|||||||
let claims = AccessTokenClaims::from_input(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: "user_00000001".to_string(),
|
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,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: 2,
|
token_version: 2,
|
||||||
|
|||||||
@@ -1132,7 +1132,8 @@ mod tests {
|
|||||||
let claims = AccessTokenClaims::from_input(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: "user_00000001".to_string(),
|
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,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: 2,
|
token_version: 2,
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option<RouteTrack
|
|||||||
("GET", "/api/auth/sessions") => {
|
("GET", "/api/auth/sessions") => {
|
||||||
Some(route_spec("auth_sessions_view", "auth", User, "anonymous"))
|
Some(route_spec("auth_sessions_view", "auth", User, "anonymous"))
|
||||||
}
|
}
|
||||||
|
("POST", "/api/auth/sessions/{id}/revoke") => {
|
||||||
|
Some(route_spec("auth_revoke_session", "auth", User, "anonymous"))
|
||||||
|
}
|
||||||
("POST", "/api/auth/refresh") => {
|
("POST", "/api/auth/refresh") => {
|
||||||
Some(route_spec("auth_refresh_success", "auth", Site, "site"))
|
Some(route_spec("auth_refresh_success", "auth", Site, "site"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ pub struct ListActiveRefreshSessionsResult {
|
|||||||
pub sessions: Vec<RefreshSessionRecord>,
|
pub sessions: Vec<RefreshSessionRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RevokeRefreshSessionResult {
|
||||||
|
pub session_id: String,
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct LogoutCurrentSessionResult {
|
pub struct LogoutCurrentSessionResult {
|
||||||
pub user: AuthUser,
|
pub user: AuthUser,
|
||||||
|
|||||||
@@ -87,10 +87,17 @@ pub struct RotateRefreshSessionInput {
|
|||||||
pub next_refresh_token_hash: String,
|
pub next_refresh_token_hash: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RevokeRefreshSessionByUserInput {
|
||||||
|
pub user_id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct LogoutCurrentSessionInput {
|
pub struct LogoutCurrentSessionInput {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub refresh_token_hash: Option<String>,
|
pub refresh_token_hash: Option<String>,
|
||||||
|
pub session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -230,6 +230,22 @@ impl PasswordEntryService {
|
|||||||
pub async fn change_password(
|
pub async fn change_password(
|
||||||
&self,
|
&self,
|
||||||
input: ChangePasswordInput,
|
input: ChangePasswordInput,
|
||||||
|
) -> Result<ChangePasswordResult, PasswordEntryError> {
|
||||||
|
self.change_password_internal(input, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn change_password_and_revoke_all_sessions(
|
||||||
|
&self,
|
||||||
|
input: ChangePasswordInput,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
) -> Result<ChangePasswordResult, PasswordEntryError> {
|
||||||
|
self.change_password_internal(input, Some(now)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn change_password_internal(
|
||||||
|
&self,
|
||||||
|
input: ChangePasswordInput,
|
||||||
|
revoke_all_sessions_at: Option<OffsetDateTime>,
|
||||||
) -> Result<ChangePasswordResult, PasswordEntryError> {
|
) -> Result<ChangePasswordResult, PasswordEntryError> {
|
||||||
validate_password(&input.new_password)?;
|
validate_password(&input.new_password)?;
|
||||||
let stored_user = self
|
let stored_user = self
|
||||||
@@ -257,7 +273,7 @@ impl PasswordEntryService {
|
|||||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||||
let user = self
|
let user = self
|
||||||
.store
|
.store
|
||||||
.set_user_password_hash(&input.user_id, password_hash)?
|
.set_user_password_hash(&input.user_id, password_hash, revoke_all_sessions_at)?
|
||||||
.ok_or(PasswordEntryError::UserNotFound)?;
|
.ok_or(PasswordEntryError::UserNotFound)?;
|
||||||
|
|
||||||
Ok(ChangePasswordResult { user })
|
Ok(ChangePasswordResult { user })
|
||||||
@@ -375,6 +391,39 @@ impl RefreshSessionService {
|
|||||||
let sessions = self.store.list_active_sessions_by_user(user_id, now)?;
|
let sessions = self.store.list_active_sessions_by_user(user_id, now)?;
|
||||||
Ok(ListActiveRefreshSessionsResult { sessions })
|
Ok(ListActiveRefreshSessionsResult { sessions })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn revoke_session_by_user_and_session(
|
||||||
|
&self,
|
||||||
|
input: RevokeRefreshSessionByUserInput,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
) -> Result<RevokeRefreshSessionResult, RefreshSessionError> {
|
||||||
|
self.store
|
||||||
|
.find_by_user_id(&input.user_id)
|
||||||
|
.map_err(map_password_store_error)?
|
||||||
|
.ok_or(RefreshSessionError::UserNotFound)?;
|
||||||
|
|
||||||
|
let Some(session_id) = normalize_required_string(&input.session_id) else {
|
||||||
|
return Err(RefreshSessionError::SessionNotFound);
|
||||||
|
};
|
||||||
|
let revoked =
|
||||||
|
self.store
|
||||||
|
.revoke_session_by_user_and_session_id(&input.user_id, &session_id, now)?;
|
||||||
|
|
||||||
|
Ok(RevokeRefreshSessionResult {
|
||||||
|
session_id,
|
||||||
|
revoked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_session_active_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
) -> Result<bool, RefreshSessionError> {
|
||||||
|
self.store
|
||||||
|
.is_session_active_for_user(user_id, session_id.trim(), now)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PhoneAuthService {
|
impl PhoneAuthService {
|
||||||
@@ -779,7 +828,7 @@ impl AuthUserService {
|
|||||||
input: LogoutCurrentSessionInput,
|
input: LogoutCurrentSessionInput,
|
||||||
now: OffsetDateTime,
|
now: OffsetDateTime,
|
||||||
) -> Result<LogoutCurrentSessionResult, LogoutError> {
|
) -> Result<LogoutCurrentSessionResult, LogoutError> {
|
||||||
if let Some(refresh_token_hash) = input
|
let revoked_by_hash = if let Some(refresh_token_hash) = input
|
||||||
.refresh_token_hash
|
.refresh_token_hash
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|value| value.trim())
|
.map(|value| value.trim())
|
||||||
@@ -788,6 +837,21 @@ impl AuthUserService {
|
|||||||
self.store
|
self.store
|
||||||
.revoke_session_by_refresh_token_hash(refresh_token_hash, now)
|
.revoke_session_by_refresh_token_hash(refresh_token_hash, now)
|
||||||
.map_err(map_refresh_error_to_logout_error)?;
|
.map_err(map_refresh_error_to_logout_error)?;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if !revoked_by_hash
|
||||||
|
&& let Some(session_id) = input
|
||||||
|
.session_id
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
self.store
|
||||||
|
.revoke_session_by_user_and_session_id(&input.user_id, session_id, now)
|
||||||
|
.map_err(map_refresh_error_to_logout_error)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = self
|
let user = self
|
||||||
@@ -1685,6 +1749,36 @@ impl InMemoryAuthStore {
|
|||||||
Ok(sessions)
|
Ok(sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_session_active_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
) -> Result<bool, RefreshSessionError> {
|
||||||
|
if session_id.trim().is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||||
|
let Some(stored) = state.sessions_by_id.get(session_id) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if stored.session.user_id != user_id || stored.session.revoked_at.is_some() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let expires_at = OffsetDateTime::parse(
|
||||||
|
&stored.session.expires_at,
|
||||||
|
&time::format_description::well_known::Rfc3339,
|
||||||
|
)
|
||||||
|
.map_err(|error| RefreshSessionError::Store(format!("会话过期时间解析失败:{error}")))?;
|
||||||
|
|
||||||
|
Ok(expires_at > now)
|
||||||
|
}
|
||||||
|
|
||||||
fn rotate_session(
|
fn rotate_session(
|
||||||
&self,
|
&self,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
@@ -1774,6 +1868,37 @@ impl InMemoryAuthStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn revoke_session_by_user_and_session_id(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
) -> Result<bool, RefreshSessionError> {
|
||||||
|
let mut state = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||||
|
let Some(stored) = state.sessions_by_id.get_mut(session_id) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if stored.session.user_id != user_id {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if stored.session.revoked_at.is_some() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let now_iso = now
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.map_err(|error| {
|
||||||
|
RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}"))
|
||||||
|
})?;
|
||||||
|
stored.session.revoked_at = Some(now_iso.clone());
|
||||||
|
stored.session.updated_at = now_iso;
|
||||||
|
self.persist_refresh_state(&state)?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
fn revoke_all_sessions_by_user_id(
|
fn revoke_all_sessions_by_user_id(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
@@ -1832,11 +1957,21 @@ impl InMemoryAuthStore {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
password_hash: String,
|
password_hash: String,
|
||||||
|
revoke_all_sessions_at: Option<OffsetDateTime>,
|
||||||
) -> Result<Option<AuthUser>, PasswordEntryError> {
|
) -> Result<Option<AuthUser>, PasswordEntryError> {
|
||||||
let mut state = self
|
let mut state = self
|
||||||
.inner
|
.inner
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||||
|
let revoke_all_sessions_at = match revoke_all_sessions_at {
|
||||||
|
Some(now) => Some(
|
||||||
|
now.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.map_err(|error| {
|
||||||
|
PasswordEntryError::Store(format!("会话吊销时间格式化失败:{error}"))
|
||||||
|
})?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
for stored_user in state.users_by_username.values_mut() {
|
for stored_user in state.users_by_username.values_mut() {
|
||||||
if stored_user.user.id != user_id {
|
if stored_user.user.id != user_id {
|
||||||
@@ -1847,6 +1982,18 @@ impl InMemoryAuthStore {
|
|||||||
stored_user.password_login_enabled = true;
|
stored_user.password_login_enabled = true;
|
||||||
stored_user.user.token_version += 1;
|
stored_user.user.token_version += 1;
|
||||||
let next_user = stored_user.user.clone();
|
let next_user = stored_user.user.clone();
|
||||||
|
if let Some(now_iso) = revoke_all_sessions_at.as_ref() {
|
||||||
|
for stored_session in state.sessions_by_id.values_mut() {
|
||||||
|
if stored_session.session.user_id != user_id
|
||||||
|
|| stored_session.session.revoked_at.is_some()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stored_session.session.revoked_at = Some(now_iso.clone());
|
||||||
|
stored_session.session.updated_at = now_iso.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
self.persist_password_state(&state)?;
|
self.persist_password_state(&state)?;
|
||||||
return Ok(Some(next_user));
|
return Ok(Some(next_user));
|
||||||
}
|
}
|
||||||
@@ -2177,6 +2324,118 @@ mod tests {
|
|||||||
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
|
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn change_password_and_revoke_all_sessions_revokes_every_refresh_session() {
|
||||||
|
let store = build_store();
|
||||||
|
let user = create_phone_login_user(store.clone(), "13800138030").await;
|
||||||
|
let password_service = build_password_service(store.clone());
|
||||||
|
let refresh_service = build_refresh_service(store.clone());
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
|
||||||
|
let first_password_user = password_service
|
||||||
|
.change_password(ChangePasswordInput {
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
current_password: None,
|
||||||
|
new_password: "secret123".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("first password should set")
|
||||||
|
.user;
|
||||||
|
let first_token_hash = hash_refresh_session_token("change-password-token-01");
|
||||||
|
let second_token_hash = hash_refresh_session_token("change-password-token-02");
|
||||||
|
refresh_service
|
||||||
|
.create_session(
|
||||||
|
CreateRefreshSessionInput {
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
refresh_token_hash: first_token_hash.clone(),
|
||||||
|
issued_by_provider: AuthLoginMethod::Password,
|
||||||
|
client_info: build_client_info(),
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.expect("first session should create");
|
||||||
|
refresh_service
|
||||||
|
.create_session(
|
||||||
|
CreateRefreshSessionInput {
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
refresh_token_hash: second_token_hash.clone(),
|
||||||
|
issued_by_provider: AuthLoginMethod::Password,
|
||||||
|
client_info: RefreshSessionClientInfo {
|
||||||
|
client_runtime: "safari".to_string(),
|
||||||
|
device_display_name: "iOS / Safari".to_string(),
|
||||||
|
..build_client_info()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
now + Duration::seconds(1),
|
||||||
|
)
|
||||||
|
.expect("second session should create");
|
||||||
|
|
||||||
|
let changed_user = password_service
|
||||||
|
.change_password_and_revoke_all_sessions(
|
||||||
|
ChangePasswordInput {
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
current_password: Some("secret123".to_string()),
|
||||||
|
new_password: "secret456".to_string(),
|
||||||
|
},
|
||||||
|
now + Duration::minutes(1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("password change should revoke all sessions")
|
||||||
|
.user;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
changed_user.token_version,
|
||||||
|
first_password_user.token_version + 1
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
refresh_service
|
||||||
|
.list_active_sessions_by_user(&user.id, now + Duration::minutes(2))
|
||||||
|
.expect("active sessions should list")
|
||||||
|
.sessions
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
for (token_hash, next_hash) in [
|
||||||
|
(
|
||||||
|
first_token_hash,
|
||||||
|
hash_refresh_session_token("change-password-token-01-next"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
second_token_hash,
|
||||||
|
hash_refresh_session_token("change-password-token-02-next"),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let refresh_error = refresh_service
|
||||||
|
.rotate_session(
|
||||||
|
RotateRefreshSessionInput {
|
||||||
|
refresh_token_hash: token_hash,
|
||||||
|
next_refresh_token_hash: next_hash,
|
||||||
|
},
|
||||||
|
now + Duration::minutes(2),
|
||||||
|
)
|
||||||
|
.expect_err("revoked session should not rotate");
|
||||||
|
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
password_service
|
||||||
|
.execute(PasswordEntryInput {
|
||||||
|
phone_number: "13800138030".to_string(),
|
||||||
|
password: "secret123".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("old password should fail"),
|
||||||
|
PasswordEntryError::InvalidCredentials
|
||||||
|
);
|
||||||
|
let login = password_service
|
||||||
|
.execute(PasswordEntryInput {
|
||||||
|
phone_number: "13800138030".to_string(),
|
||||||
|
password: "secret456".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("new password should login");
|
||||||
|
assert_eq!(login.user.id, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn password_entry_rejects_wrong_password_after_set() {
|
async fn password_entry_rejects_wrong_password_after_set() {
|
||||||
let store = build_store();
|
let store = build_store();
|
||||||
@@ -2524,6 +2783,7 @@ mod tests {
|
|||||||
LogoutCurrentSessionInput {
|
LogoutCurrentSessionInput {
|
||||||
user_id: user.id.clone(),
|
user_id: user.id.clone(),
|
||||||
refresh_token_hash: Some(refresh_token_hash.clone()),
|
refresh_token_hash: Some(refresh_token_hash.clone()),
|
||||||
|
session_id: None,
|
||||||
},
|
},
|
||||||
OffsetDateTime::now_utc(),
|
OffsetDateTime::now_utc(),
|
||||||
)
|
)
|
||||||
@@ -2543,6 +2803,148 @@ mod tests {
|
|||||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn revoke_session_by_user_and_session_revokes_only_target_without_token_bump() {
|
||||||
|
let store = build_store();
|
||||||
|
let user = create_phone_login_user(store.clone(), "13800138028").await;
|
||||||
|
let refresh_service = build_refresh_service(store.clone());
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let first_token_hash = hash_refresh_session_token("revoke-target-token");
|
||||||
|
let second_token_hash = hash_refresh_session_token("revoke-current-token");
|
||||||
|
|
||||||
|
let target = refresh_service
|
||||||
|
.create_session(
|
||||||
|
CreateRefreshSessionInput {
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
refresh_token_hash: first_token_hash.clone(),
|
||||||
|
issued_by_provider: AuthLoginMethod::Password,
|
||||||
|
client_info: build_client_info(),
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.expect("target session should create");
|
||||||
|
let current = refresh_service
|
||||||
|
.create_session(
|
||||||
|
CreateRefreshSessionInput {
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
refresh_token_hash: second_token_hash,
|
||||||
|
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::seconds(1),
|
||||||
|
)
|
||||||
|
.expect("current session should create");
|
||||||
|
|
||||||
|
let revoke = refresh_service
|
||||||
|
.revoke_session_by_user_and_session(
|
||||||
|
RevokeRefreshSessionByUserInput {
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
session_id: target.session.session_id.clone(),
|
||||||
|
},
|
||||||
|
now + Duration::minutes(1),
|
||||||
|
)
|
||||||
|
.expect("target session should revoke");
|
||||||
|
|
||||||
|
assert!(revoke.revoked);
|
||||||
|
assert_eq!(revoke.session_id, target.session.session_id);
|
||||||
|
assert!(
|
||||||
|
!refresh_service
|
||||||
|
.is_session_active_for_user(
|
||||||
|
&user.id,
|
||||||
|
&target.session.session_id,
|
||||||
|
now + Duration::minutes(2)
|
||||||
|
)
|
||||||
|
.expect("target active check should succeed")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
refresh_service
|
||||||
|
.is_session_active_for_user(
|
||||||
|
&user.id,
|
||||||
|
¤t.session.session_id,
|
||||||
|
now + Duration::minutes(2)
|
||||||
|
)
|
||||||
|
.expect("current active check should succeed")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.find_by_user_id(&user.id)
|
||||||
|
.expect("user lookup should succeed")
|
||||||
|
.expect("user should exist")
|
||||||
|
.user
|
||||||
|
.token_version,
|
||||||
|
user.token_version
|
||||||
|
);
|
||||||
|
|
||||||
|
let refresh_error = refresh_service
|
||||||
|
.rotate_session(
|
||||||
|
RotateRefreshSessionInput {
|
||||||
|
refresh_token_hash: first_token_hash,
|
||||||
|
next_refresh_token_hash: hash_refresh_session_token("revoke-target-next"),
|
||||||
|
},
|
||||||
|
now + Duration::minutes(2),
|
||||||
|
)
|
||||||
|
.expect_err("revoked target should not rotate");
|
||||||
|
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn logout_current_session_uses_session_id_when_refresh_cookie_missing() {
|
||||||
|
let store = build_store();
|
||||||
|
let user = create_phone_login_user(store.clone(), "13800138029").await;
|
||||||
|
let refresh_service = build_refresh_service(store.clone());
|
||||||
|
let user_service = build_user_service(store);
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let refresh_token_hash = hash_refresh_session_token("logout-sid-token");
|
||||||
|
let session = refresh_service
|
||||||
|
.create_session(
|
||||||
|
CreateRefreshSessionInput {
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
refresh_token_hash: refresh_token_hash.clone(),
|
||||||
|
issued_by_provider: AuthLoginMethod::Password,
|
||||||
|
client_info: build_client_info(),
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.expect("session should create");
|
||||||
|
|
||||||
|
let result = user_service
|
||||||
|
.logout_current_session(
|
||||||
|
LogoutCurrentSessionInput {
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
refresh_token_hash: None,
|
||||||
|
session_id: Some(session.session.session_id.clone()),
|
||||||
|
},
|
||||||
|
now + Duration::minutes(1),
|
||||||
|
)
|
||||||
|
.expect("logout should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.user.token_version, user.token_version + 1);
|
||||||
|
assert!(
|
||||||
|
!refresh_service
|
||||||
|
.is_session_active_for_user(
|
||||||
|
&user.id,
|
||||||
|
&session.session.session_id,
|
||||||
|
now + Duration::minutes(2)
|
||||||
|
)
|
||||||
|
.expect("session active check should succeed")
|
||||||
|
);
|
||||||
|
|
||||||
|
let refresh_error = refresh_service
|
||||||
|
.rotate_session(
|
||||||
|
RotateRefreshSessionInput {
|
||||||
|
refresh_token_hash,
|
||||||
|
next_refresh_token_hash: hash_refresh_session_token("logout-sid-next"),
|
||||||
|
},
|
||||||
|
now + Duration::minutes(2),
|
||||||
|
)
|
||||||
|
.expect_err("sid-revoked session should fail");
|
||||||
|
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() {
|
async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() {
|
||||||
let store = build_store();
|
let store = build_store();
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ pub struct AuthSessionsResponse {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AuthSessionSummaryPayload {
|
pub struct AuthSessionSummaryPayload {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
|
pub session_ids: Vec<String>,
|
||||||
|
pub session_count: u32,
|
||||||
pub client_type: String,
|
pub client_type: String,
|
||||||
pub client_runtime: String,
|
pub client_runtime: String,
|
||||||
pub client_platform: String,
|
pub client_platform: String,
|
||||||
@@ -144,6 +146,11 @@ pub struct LogoutAllResponse {
|
|||||||
pub ok: bool,
|
pub ok: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct RevokeAuthSessionResponse {
|
||||||
|
pub ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PhoneSendCodeRequest {
|
pub struct PhoneSendCodeRequest {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ function renderAccountModal(overrides?: {
|
|||||||
riskBlocks?: AuthRiskBlockSummary[];
|
riskBlocks?: AuthRiskBlockSummary[];
|
||||||
sessions?: AuthSessionSummary[];
|
sessions?: AuthSessionSummary[];
|
||||||
auditLogs?: AuthAuditLogEntry[];
|
auditLogs?: AuthAuditLogEntry[];
|
||||||
|
onRevokeSession?: (session: AuthSessionSummary) => Promise<void>;
|
||||||
|
revokingSessionIds?: string[];
|
||||||
initialSection?:
|
initialSection?:
|
||||||
| 'appearance'
|
| 'appearance'
|
||||||
| 'account'
|
| 'account'
|
||||||
@@ -63,7 +65,10 @@ function renderAccountModal(overrides?: {
|
|||||||
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
|
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
|
||||||
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
|
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
|
||||||
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
|
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
|
||||||
onRevokeSession={vi.fn().mockResolvedValue(undefined)}
|
onRevokeSession={
|
||||||
|
overrides?.onRevokeSession ?? vi.fn().mockResolvedValue(undefined)
|
||||||
|
}
|
||||||
|
revokingSessionIds={overrides?.revokingSessionIds ?? []}
|
||||||
changePhoneCaptchaChallenge={null}
|
changePhoneCaptchaChallenge={null}
|
||||||
onSendChangePhoneCode={vi.fn().mockResolvedValue({
|
onSendChangePhoneCode={vi.fn().mockResolvedValue({
|
||||||
cooldownSeconds: 60,
|
cooldownSeconds: 60,
|
||||||
@@ -75,6 +80,30 @@ function renderAccountModal(overrides?: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSession(
|
||||||
|
overrides: Partial<AuthSessionSummary> = {},
|
||||||
|
): AuthSessionSummary {
|
||||||
|
return {
|
||||||
|
sessionId: 'usess_1',
|
||||||
|
sessionIds: ['usess_1'],
|
||||||
|
sessionCount: 1,
|
||||||
|
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: false,
|
||||||
|
createdAt: '2026-05-01T10:00:00.000Z',
|
||||||
|
lastSeenAt: '2026-05-01T10:30:00.000Z',
|
||||||
|
expiresAt: '2026-06-01T10:30:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test('settings header uses a generic title instead of the phone number', () => {
|
test('settings header uses a generic title instead of the phone number', () => {
|
||||||
renderAccountModal();
|
renderAccountModal();
|
||||||
|
|
||||||
@@ -238,8 +267,10 @@ test('account panel includes merged security devices and audit sections', async
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
sessions: [
|
sessions: [
|
||||||
{
|
buildSession({
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
|
sessionIds: ['session-1'],
|
||||||
|
sessionCount: 1,
|
||||||
clientType: 'mobile',
|
clientType: 'mobile',
|
||||||
clientRuntime: 'ios',
|
clientRuntime: 'ios',
|
||||||
clientPlatform: 'wechat',
|
clientPlatform: 'wechat',
|
||||||
@@ -253,7 +284,7 @@ test('account panel includes merged security devices and audit sections', async
|
|||||||
lastSeenAt: '2026-04-20T09:00:00.000Z',
|
lastSeenAt: '2026-04-20T09:00:00.000Z',
|
||||||
expiresAt: '2026-04-27T09:00:00.000Z',
|
expiresAt: '2026-04-27T09:00:00.000Z',
|
||||||
ipMasked: '10.0.*.*',
|
ipMasked: '10.0.*.*',
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
auditLogs: [
|
auditLogs: [
|
||||||
{
|
{
|
||||||
@@ -294,3 +325,77 @@ test('legacy nested section requests now open the merged account panel', () => {
|
|||||||
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
|
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
|
||||||
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
|
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('current merged session group hides kick action and shows count', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderAccountModal({
|
||||||
|
sessions: [
|
||||||
|
buildSession({
|
||||||
|
sessionId: 'usess_current',
|
||||||
|
sessionIds: ['usess_current', 'usess_rotated'],
|
||||||
|
sessionCount: 2,
|
||||||
|
isCurrent: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||||
|
|
||||||
|
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||||
|
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(accountDialog).queryByRole('button', { name: '踢下线' }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote merged session group can be revoked with loading state', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const remoteSession = buildSession({
|
||||||
|
sessionId: 'usess_remote',
|
||||||
|
sessionIds: ['usess_remote', 'usess_remote_rotated'],
|
||||||
|
sessionCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAccountModal({
|
||||||
|
sessions: [remoteSession],
|
||||||
|
onRevokeSession,
|
||||||
|
revokingSessionIds: ['usess_remote'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||||
|
|
||||||
|
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||||
|
const revokeButton = within(accountDialog).getByRole('button', {
|
||||||
|
name: '处理中...',
|
||||||
|
}) as HTMLButtonElement;
|
||||||
|
expect(revokeButton.disabled).toBe(true);
|
||||||
|
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
|
||||||
|
expect(onRevokeSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote session revoke passes the grouped session payload', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const remoteSession = buildSession({
|
||||||
|
sessionId: 'usess_remote',
|
||||||
|
sessionIds: ['usess_remote', 'usess_remote_rotated'],
|
||||||
|
sessionCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderAccountModal({
|
||||||
|
sessions: [remoteSession],
|
||||||
|
onRevokeSession,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByRole('dialog', { name: '账号信息' })).getByRole(
|
||||||
|
'button',
|
||||||
|
{ name: '踢下线' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onRevokeSession).toHaveBeenCalledWith(remoteSession);
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ type AccountModalProps = {
|
|||||||
onRefreshSessions: () => Promise<void>;
|
onRefreshSessions: () => Promise<void>;
|
||||||
onLogoutAll: () => Promise<void>;
|
onLogoutAll: () => Promise<void>;
|
||||||
onRefreshAuditLogs: () => Promise<void>;
|
onRefreshAuditLogs: () => Promise<void>;
|
||||||
onRevokeSession: (sessionId: string) => Promise<void>;
|
onRevokeSession: (session: AuthSessionSummary) => Promise<void>;
|
||||||
|
revokingSessionIds: string[];
|
||||||
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
|
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
|
||||||
onSendChangePhoneCode: (
|
onSendChangePhoneCode: (
|
||||||
phone: string,
|
phone: string,
|
||||||
@@ -298,6 +299,7 @@ export function AccountModal({
|
|||||||
onLogoutAll,
|
onLogoutAll,
|
||||||
onRefreshAuditLogs,
|
onRefreshAuditLogs,
|
||||||
onRevokeSession,
|
onRevokeSession,
|
||||||
|
revokingSessionIds,
|
||||||
changePhoneCaptchaChallenge,
|
changePhoneCaptchaChallenge,
|
||||||
onSendChangePhoneCode,
|
onSendChangePhoneCode,
|
||||||
onChangePhone,
|
onChangePhone,
|
||||||
@@ -759,41 +761,55 @@ export function AccountModal({
|
|||||||
正在读取当前登录设备...
|
正在读取当前登录设备...
|
||||||
</div>
|
</div>
|
||||||
) : sessions.length > 0 ? (
|
) : sessions.length > 0 ? (
|
||||||
sessions.map((session) => (
|
sessions.map((session) => {
|
||||||
<div
|
const isRevoking = revokingSessionIds.includes(
|
||||||
key={session.sessionId}
|
session.sessionId,
|
||||||
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
);
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
return (
|
||||||
<span>{session.clientLabel}</span>
|
<div
|
||||||
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
|
key={session.sessionId}
|
||||||
{session.isCurrent ? '当前设备' : '已登录'}
|
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||||||
</span>
|
>
|
||||||
</div>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
|
<span>{session.clientLabel}</span>
|
||||||
最近活跃:{formatSessionTime(session.lastSeenAt)}
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
</div>
|
{session.sessionCount > 1 ? (
|
||||||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||||
到期时间:{formatSessionTime(session.expiresAt)}
|
{session.sessionCount} 个会话
|
||||||
</div>
|
</span>
|
||||||
{session.ipMasked ? (
|
) : null}
|
||||||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
|
||||||
IP:{session.ipMasked}
|
{session.isCurrent ? '当前设备' : '已登录'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||||
{!session.isCurrent ? (
|
最近活跃:{formatSessionTime(session.lastSeenAt)}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||||
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
|
到期时间:{formatSessionTime(session.expiresAt)}
|
||||||
onClick={() => {
|
</div>
|
||||||
void onRevokeSession(session.sessionId);
|
{session.ipMasked ? (
|
||||||
}}
|
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||||
>
|
IP:{session.ipMasked}
|
||||||
踢下线
|
</div>
|
||||||
</button>
|
) : null}
|
||||||
) : null}
|
{!session.isCurrent ? (
|
||||||
</div>
|
<button
|
||||||
))
|
type="button"
|
||||||
|
className="platform-button platform-button--danger mt-3 h-9 min-h-0 px-3 text-xs"
|
||||||
|
disabled={isRevoking}
|
||||||
|
onClick={() => {
|
||||||
|
void onRevokeSession(session);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRevoking ? '处理中...' : '踢下线'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||||
暂无可展示的登录设备。
|
暂无可展示的登录设备。
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { beforeEach, expect, test, vi } from 'vitest';
|
import { beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type { AuthUser } from '../../services/authService';
|
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
|
||||||
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
|
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
|
||||||
import { AuthGate } from './AuthGate';
|
import { AuthGate } from './AuthGate';
|
||||||
import { useAuthUi } from './AuthUiContext';
|
import { useAuthUi } from './AuthUiContext';
|
||||||
@@ -23,6 +23,10 @@ const authMocks = vi.hoisted(() => ({
|
|||||||
logoutAuthUser: vi.fn(),
|
logoutAuthUser: vi.fn(),
|
||||||
redeemRegistrationInviteCode: vi.fn(),
|
redeemRegistrationInviteCode: vi.fn(),
|
||||||
resetPassword: vi.fn(),
|
resetPassword: vi.fn(),
|
||||||
|
getAuthAuditLogs: vi.fn(),
|
||||||
|
getAuthRiskBlocks: vi.fn(),
|
||||||
|
getAuthSessions: vi.fn(),
|
||||||
|
revokeAuthSessions: vi.fn(),
|
||||||
sendPhoneLoginCode: vi.fn(),
|
sendPhoneLoginCode: vi.fn(),
|
||||||
startWechatLogin: vi.fn(),
|
startWechatLogin: vi.fn(),
|
||||||
consumeAuthCallbackResult: vi.fn(),
|
consumeAuthCallbackResult: vi.fn(),
|
||||||
@@ -42,11 +46,11 @@ vi.mock('../../services/authService', () => ({
|
|||||||
changePhoneNumber: vi.fn(),
|
changePhoneNumber: vi.fn(),
|
||||||
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
||||||
getStoredLastLoginPhone: vi.fn(() => ''),
|
getStoredLastLoginPhone: vi.fn(() => ''),
|
||||||
getAuthAuditLogs: vi.fn(),
|
getAuthAuditLogs: authMocks.getAuthAuditLogs,
|
||||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||||
getAuthRiskBlocks: vi.fn(),
|
getAuthRiskBlocks: authMocks.getAuthRiskBlocks,
|
||||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||||
getAuthSessions: vi.fn(),
|
getAuthSessions: authMocks.getAuthSessions,
|
||||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||||
liftAuthRiskBlock: vi.fn(),
|
liftAuthRiskBlock: vi.fn(),
|
||||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||||
@@ -54,7 +58,7 @@ vi.mock('../../services/authService', () => ({
|
|||||||
logoutAuthUser: authMocks.logoutAuthUser,
|
logoutAuthUser: authMocks.logoutAuthUser,
|
||||||
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
|
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
|
||||||
resetPassword: authMocks.resetPassword,
|
resetPassword: authMocks.resetPassword,
|
||||||
revokeAuthSession: vi.fn(),
|
revokeAuthSessions: authMocks.revokeAuthSessions,
|
||||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||||
setStoredLastLoginPhone: vi.fn(),
|
setStoredLastLoginPhone: vi.fn(),
|
||||||
startWechatLogin: authMocks.startWechatLogin,
|
startWechatLogin: authMocks.startWechatLogin,
|
||||||
@@ -73,9 +77,12 @@ vi.mock('../../hooks/useGameSettings', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./AccountModal', () => ({
|
vi.mock('./AccountModal', async () => {
|
||||||
AccountModal: () => null,
|
const actual =
|
||||||
}));
|
await vi.importActual<typeof import('./AccountModal')>('./AccountModal');
|
||||||
|
|
||||||
|
return actual;
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('./BindPhoneScreen', () => ({
|
vi.mock('./BindPhoneScreen', () => ({
|
||||||
BindPhoneScreen: () => <div>绑定手机号</div>,
|
BindPhoneScreen: () => <div>绑定手机号</div>,
|
||||||
@@ -116,6 +123,10 @@ beforeEach(() => {
|
|||||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||||
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
|
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
|
||||||
authMocks.logoutAuthUser.mockResolvedValue(undefined);
|
authMocks.logoutAuthUser.mockResolvedValue(undefined);
|
||||||
|
authMocks.getAuthAuditLogs.mockResolvedValue([]);
|
||||||
|
authMocks.getAuthRiskBlocks.mockResolvedValue([]);
|
||||||
|
authMocks.getAuthSessions.mockResolvedValue([]);
|
||||||
|
authMocks.revokeAuthSessions.mockResolvedValue(undefined);
|
||||||
authMocks.redeemRegistrationInviteCode.mockResolvedValue({
|
authMocks.redeemRegistrationInviteCode.mockResolvedValue({
|
||||||
center: {
|
center: {
|
||||||
inviteCode: 'SY12345678',
|
inviteCode: 'SY12345678',
|
||||||
@@ -205,6 +216,21 @@ function LogoutStateProbe() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AccountPanelProbe() {
|
||||||
|
const authUi = useAuthUi();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
authUi?.openAccountModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
打开账号面板
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||||
availableLoginMethods: ['phone'],
|
availableLoginMethods: ['phone'],
|
||||||
@@ -786,3 +812,101 @@ test('auth gate separates sms and password login by tabs', async () => {
|
|||||||
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
|
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('auth gate revokes merged session group and refreshes sessions', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const initialSessions: AuthSessionSummary[] = [
|
||||||
|
{
|
||||||
|
sessionId: 'usess_remote',
|
||||||
|
sessionIds: ['usess_remote', 'usess_remote_rotated'],
|
||||||
|
sessionCount: 2,
|
||||||
|
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: false,
|
||||||
|
createdAt: '2026-05-01T10:00:00.000Z',
|
||||||
|
lastSeenAt: '2026-05-01T10:30:00.000Z',
|
||||||
|
expiresAt: '2026-06-01T10:30:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||||
|
user: mockUser,
|
||||||
|
availableLoginMethods: ['phone'],
|
||||||
|
});
|
||||||
|
authMocks.getAuthSessions
|
||||||
|
.mockResolvedValueOnce(initialSessions)
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthGate>
|
||||||
|
<AccountPanelProbe />
|
||||||
|
</AuthGate>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: '打开账号面板' }));
|
||||||
|
const accountDialog = await screen.findByRole('dialog', {
|
||||||
|
name: '账号信息',
|
||||||
|
});
|
||||||
|
await user.click(within(accountDialog).getByRole('button', { name: '踢下线' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([
|
||||||
|
'usess_remote',
|
||||||
|
'usess_remote_rotated',
|
||||||
|
]);
|
||||||
|
expect(authMocks.getAuthSessions).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth gate clears account state after password change', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||||
|
user: mockUser,
|
||||||
|
availableLoginMethods: ['phone'],
|
||||||
|
});
|
||||||
|
authMocks.getAuthSessions.mockResolvedValue([]);
|
||||||
|
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthGate>
|
||||||
|
<div>
|
||||||
|
<LogoutStateProbe />
|
||||||
|
<AccountPanelProbe />
|
||||||
|
</div>
|
||||||
|
</AuthGate>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||||
|
await user.click(screen.getByRole('button', { name: '打开账号面板' }));
|
||||||
|
|
||||||
|
const accountDialog = await screen.findByRole('dialog', {
|
||||||
|
name: '账号信息',
|
||||||
|
});
|
||||||
|
await user.click(
|
||||||
|
within(accountDialog).getByRole('button', { name: '修改密码' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordDialog = await screen.findByRole('dialog', {
|
||||||
|
name: '修改登录密码',
|
||||||
|
});
|
||||||
|
await user.type(within(passwordDialog).getByLabelText('当前密码'), 'oldpass1');
|
||||||
|
await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1');
|
||||||
|
await user.click(
|
||||||
|
within(passwordDialog).getByRole('button', { name: '确认修改密码' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(authMocks.changePassword).toHaveBeenCalledWith(
|
||||||
|
'oldpass1',
|
||||||
|
'newpass1',
|
||||||
|
);
|
||||||
|
expect(screen.getByText('当前用户:未登录')).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('dialog', { name: '账号信息' })).toBeNull();
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
logoutAuthUser,
|
logoutAuthUser,
|
||||||
redeemRegistrationInviteCode,
|
redeemRegistrationInviteCode,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
revokeAuthSession,
|
revokeAuthSessions,
|
||||||
sendPhoneLoginCode,
|
sendPhoneLoginCode,
|
||||||
setStoredLastLoginPhone,
|
setStoredLastLoginPhone,
|
||||||
startWechatLogin,
|
startWechatLogin,
|
||||||
@@ -121,6 +121,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
useState<PlatformSettingsSection | null>(null);
|
useState<PlatformSettingsSection | null>(null);
|
||||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||||
|
const [revokingSessionIds, setRevokingSessionIds] = useState<string[]>([]);
|
||||||
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
|
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
|
||||||
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
|
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
|
||||||
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
|
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
|
||||||
@@ -167,6 +168,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
setSettingsEntryMode('settings');
|
setSettingsEntryMode('settings');
|
||||||
setInitialSettingsSection(null);
|
setInitialSettingsSection(null);
|
||||||
setSessions([]);
|
setSessions([]);
|
||||||
|
setRevokingSessionIds([]);
|
||||||
setAuditLogs([]);
|
setAuditLogs([]);
|
||||||
setRiskBlocks([]);
|
setRiskBlocks([]);
|
||||||
setLoginCaptchaChallenge(null);
|
setLoginCaptchaChallenge(null);
|
||||||
@@ -691,6 +693,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
loadingRiskBlocks={loadingRiskBlocks}
|
loadingRiskBlocks={loadingRiskBlocks}
|
||||||
loadingSessions={loadingSessions}
|
loadingSessions={loadingSessions}
|
||||||
loadingAuditLogs={loadingAuditLogs}
|
loadingAuditLogs={loadingAuditLogs}
|
||||||
|
revokingSessionIds={revokingSessionIds}
|
||||||
isHydratingSettings={settings.isHydratingSettings}
|
isHydratingSettings={settings.isHydratingSettings}
|
||||||
isPersistingSettings={settings.isPersistingSettings}
|
isPersistingSettings={settings.isPersistingSettings}
|
||||||
settingsError={settings.settingsError}
|
settingsError={settings.settingsError}
|
||||||
@@ -752,14 +755,17 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
setLoadingAuditLogs(false);
|
setLoadingAuditLogs(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onRevokeSession={async (sessionId) => {
|
onRevokeSession={async (session) => {
|
||||||
|
const sessionIds =
|
||||||
|
session.sessionIds.length > 0
|
||||||
|
? session.sessionIds
|
||||||
|
: [session.sessionId];
|
||||||
|
setRevokingSessionIds((current) =>
|
||||||
|
Array.from(new Set([...current, session.sessionId])),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await revokeAuthSession(sessionId);
|
await revokeAuthSessions(sessionIds);
|
||||||
setSessions((current) =>
|
setSessions(await getAuthSessions());
|
||||||
current.filter(
|
|
||||||
(session) => session.sessionId !== sessionId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setAuditLogs(await getAuthAuditLogs());
|
setAuditLogs(await getAuthAuditLogs());
|
||||||
} catch (revokeError) {
|
} catch (revokeError) {
|
||||||
setError(
|
setError(
|
||||||
@@ -767,6 +773,10 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
? revokeError.message
|
? revokeError.message
|
||||||
: '移除登录设备失败,请稍后再试。',
|
: '移除登录设备失败,请稍后再试。',
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
setRevokingSessionIds((current) =>
|
||||||
|
current.filter((id) => id !== session.sessionId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLogoutAll={logoutAllSessions}
|
onLogoutAll={logoutAllSessions}
|
||||||
@@ -795,11 +805,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
setUser(nextUser);
|
setUser(nextUser);
|
||||||
}}
|
}}
|
||||||
onChangePassword={async (currentPassword, newPassword) => {
|
onChangePassword={async (currentPassword, newPassword) => {
|
||||||
const nextUser = await changePassword(
|
await changePassword(currentPassword, newPassword);
|
||||||
currentPassword,
|
clearLocalAuthenticatedState();
|
||||||
newPassword,
|
|
||||||
);
|
|
||||||
setUser(nextUser);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
authEntry,
|
authEntry,
|
||||||
bindWechatPhone,
|
bindWechatPhone,
|
||||||
changePhoneNumber,
|
changePhoneNumber,
|
||||||
|
changePassword,
|
||||||
consumeAuthCallbackResult,
|
consumeAuthCallbackResult,
|
||||||
getAuthAuditLogs,
|
getAuthAuditLogs,
|
||||||
getAuthLoginOptions,
|
getAuthLoginOptions,
|
||||||
@@ -33,6 +34,8 @@ import {
|
|||||||
loginWithPhoneCode,
|
loginWithPhoneCode,
|
||||||
logoutAllAuthSessions,
|
logoutAllAuthSessions,
|
||||||
redeemRegistrationInviteCode,
|
redeemRegistrationInviteCode,
|
||||||
|
revokeAuthSession,
|
||||||
|
revokeAuthSessions,
|
||||||
sendPhoneLoginCode,
|
sendPhoneLoginCode,
|
||||||
startWechatLogin,
|
startWechatLogin,
|
||||||
updateAuthProfile,
|
updateAuthProfile,
|
||||||
@@ -154,6 +157,44 @@ describe('authService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('change password clears local auth session after backend success', async () => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'genarrative:access-token',
|
||||||
|
'jwt-before-password-change',
|
||||||
|
);
|
||||||
|
apiClientMocks.requestJson.mockResolvedValue({
|
||||||
|
user: {
|
||||||
|
id: 'user_1',
|
||||||
|
publicUserCode: 'SY-00000001',
|
||||||
|
username: 'phone_00000001',
|
||||||
|
displayName: '旅人甲',
|
||||||
|
avatarUrl: null,
|
||||||
|
phoneNumberMasked: '138****8000',
|
||||||
|
loginMethod: 'password',
|
||||||
|
bindingStatus: 'active',
|
||||||
|
wechatBound: false,
|
||||||
|
createdAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await changePassword(' old-password ', ' new-password ');
|
||||||
|
|
||||||
|
expect(user.id).toBe('user_1');
|
||||||
|
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||||
|
'/api/auth/password/change',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
currentPassword: 'old-password',
|
||||||
|
newPassword: 'new-password',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
'修改密码失败',
|
||||||
|
);
|
||||||
|
expect(getStoredAccessToken()).toBe('');
|
||||||
|
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('sends phone login code through the auth endpoint', async () => {
|
it('sends phone login code through the auth endpoint', async () => {
|
||||||
apiClientMocks.requestJson.mockResolvedValue({
|
apiClientMocks.requestJson.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -475,8 +516,15 @@ describe('authService', () => {
|
|||||||
sessions: [
|
sessions: [
|
||||||
{
|
{
|
||||||
sessionId: 'usess_1',
|
sessionId: 'usess_1',
|
||||||
|
sessionIds: ['usess_1', 'usess_2'],
|
||||||
|
sessionCount: 2,
|
||||||
clientType: 'browser',
|
clientType: 'browser',
|
||||||
|
clientRuntime: 'chrome',
|
||||||
|
clientPlatform: 'windows',
|
||||||
clientLabel: '网页端浏览器',
|
clientLabel: '网页端浏览器',
|
||||||
|
deviceDisplayName: 'Windows / Chrome',
|
||||||
|
miniProgramAppId: null,
|
||||||
|
miniProgramEnv: null,
|
||||||
userAgent: 'Mozilla/5.0',
|
userAgent: 'Mozilla/5.0',
|
||||||
ipMasked: '127.0.*.*',
|
ipMasked: '127.0.*.*',
|
||||||
isCurrent: true,
|
isCurrent: true,
|
||||||
@@ -490,6 +538,46 @@ describe('authService', () => {
|
|||||||
const sessions = await getAuthSessions();
|
const sessions = await getAuthSessions();
|
||||||
|
|
||||||
expect(sessions).toHaveLength(1);
|
expect(sessions).toHaveLength(1);
|
||||||
|
expect(sessions[0].sessionIds).toEqual(['usess_1', 'usess_2']);
|
||||||
|
expect(sessions[0].sessionCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes a single auth session by backend route', async () => {
|
||||||
|
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
await revokeAuthSession('usess_1');
|
||||||
|
|
||||||
|
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||||
|
'/api/auth/sessions/usess_1/revoke',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
'移除登录设备失败',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes grouped auth sessions once per unique session id', async () => {
|
||||||
|
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
await revokeAuthSessions([' usess_1 ', 'usess_2', 'usess_1', '']);
|
||||||
|
|
||||||
|
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(2);
|
||||||
|
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'/api/auth/sessions/usess_1/revoke',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
'移除登录设备失败',
|
||||||
|
);
|
||||||
|
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'/api/auth/sessions/usess_2/revoke',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
'移除登录设备失败',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads recent auth audit logs', async () => {
|
it('loads recent auth audit logs', async () => {
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ export async function changePassword(
|
|||||||
'修改密码失败',
|
'修改密码失败',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
clearAuthSession();
|
||||||
return response.user;
|
return response.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +442,16 @@ export async function revokeAuthSession(sessionId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function revokeAuthSessions(sessionIds: string[]) {
|
||||||
|
const uniqueSessionIds = Array.from(
|
||||||
|
new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
uniqueSessionIds.map((sessionId) => revokeAuthSession(sessionId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAuthAuditLogs() {
|
export async function getAuthAuditLogs() {
|
||||||
const response = await requestJson<AuthAuditLogsResponse>(
|
const response = await requestJson<AuthAuditLogsResponse>(
|
||||||
'/api/auth/audit-logs',
|
'/api/auth/audit-logs',
|
||||||
|
|||||||
Reference in New Issue
Block a user