From 4fecf9c9750dbc0c9897bad18604242d3bd692cc Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 15:04:37 +0800 Subject: [PATCH 1/2] fix(auth): tighten refresh session revocation --- .hermes/shared-memory/decision-log.md | 17 +- .../AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md | 66 ++- ...KEND_TRACKING_EVENT_COVERAGE_2026-05-09.md | 1 + ...RD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md | 5 +- docs/technical/README.md | 4 +- .../RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md | 19 +- ...G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md | 4 +- ...REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md | 41 +- packages/shared/src/contracts/auth.ts | 2 + server-rs/crates/api-server/src/ai_tasks.rs | 3 +- server-rs/crates/api-server/src/app.rs | 402 +++++++++++++++-- server-rs/crates/api-server/src/auth.rs | 28 ++ .../crates/api-server/src/auth_sessions.rs | 221 ++++++++-- .../src/creation_agent_document_input.rs | 7 +- server-rs/crates/api-server/src/llm.rs | 3 +- server-rs/crates/api-server/src/logout.rs | 1 + .../api-server/src/password_management.rs | 37 +- .../api-server/src/runtime_browse_history.rs | 5 +- .../api-server/src/runtime_inventory.rs | 5 +- .../crates/api-server/src/runtime_profile.rs | 3 +- .../crates/api-server/src/runtime_save.rs | 3 +- .../crates/api-server/src/runtime_settings.rs | 5 +- server-rs/crates/api-server/src/state.rs | 48 +++ .../crates/api-server/src/story_battles.rs | 3 +- .../crates/api-server/src/story_sessions.rs | 3 +- server-rs/crates/api-server/src/tracking.rs | 3 + .../crates/module-auth/src/application.rs | 6 + server-rs/crates/module-auth/src/commands.rs | 7 + server-rs/crates/module-auth/src/lib.rs | 406 +++++++++++++++++- server-rs/crates/shared-contracts/src/auth.rs | 7 + src/components/auth/AccountModal.test.tsx | 111 ++++- src/components/auth/AccountModal.tsx | 86 ++-- src/components/auth/AuthGate.test.tsx | 140 +++++- src/components/auth/AuthGate.tsx | 33 +- src/services/authService.test.ts | 88 ++++ src/services/authService.ts | 11 + 36 files changed, 1664 insertions(+), 170 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index e4985c17..42531e86 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-05-13 修改密码后全设备强制下线 + +- 背景:修改密码原本只递增 `token_version`,旧 access token 会失效,但旧 refresh cookie 仍可通过 `/api/auth/refresh` 重新签发新 token,不符合“改密后全设备强制下线”的账号安全预期。 +- 决策:`POST /api/auth/password/change` 成功后必须在同一认证真相更新中撤销该用户全部 active `refresh_session`,继续递增 `token_version`,响应清除当前 refresh cookie;前端 `changePassword` 成功后清空本地 access token 并回到未登录态。用户需要使用新密码重新登录。 +- 影响范围:`module-auth` 修改密码用例、`api-server` password management route、`AuthGate`、`authService`、密码登录/重置技术文档。 +- 验证方式:执行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml password_change_allows_login_with_new_password_only -- --nocapture`、`npm run test -- AuthGate.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`、`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`。 + +## 2026-05-13 refresh_session 会话组后端聚合与远端踢下线 + +- 背景:账号安全页中同设备同 IP 的多条 active `refresh_session` 会重复展示;退出登录没有稳定撤销当前 refresh session;前端“踢下线”只做本地状态变化,未真正让远端设备失效。 +- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions,响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session,使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session,并继续递增 `token_version`。 +- 影响范围:`module-auth` refresh session service、`api-server` auth middleware/logout/sessions route、`shared-contracts`/TS auth contract、`AuthGate`、`AccountModal`、认证会话技术文档和路由/埋点索引。 +- 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture`、`npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`,并用 `npm run api-server` 检查 `/healthz`。 +- 关联文档:`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`、`docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`、`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`。 + ## 2026-05-12 抓大鹅入口素材风格改为 2D 常见素材风格 - 背景:抓大鹅草稿素材生成已经收敛为多视角 2D 图片素材,但入口页和旧参考图仍沿用黏土、低多边形、塑料、木雕、体素、金属等偏 3D 素材语言,容易让后续生成链路和用户预期继续漂移。 @@ -55,7 +71,6 @@ - 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。 - 验证方式:执行 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、触碰文件 ESLint、`npm run check:encoding`。 - 关联文档:`docs/prd/PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md`。 - ## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权 - 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`,H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。 diff --git a/docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md b/docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md index 9e860fdf..fd437e06 100644 --- a/docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md +++ b/docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md @@ -10,6 +10,7 @@ 2. 当前设备识别方式与 `isCurrent` 语义 3. 多端登录识别字段如何从 `refresh_session` 派生到 DTO 4. Rust 首版在 Axum + 进程内 `module-auth` 下的最小实现边界 +5. `2026-05-13` 会话组合并展示与远端踢下线闭环修复口径 ## 2. 当前基线 @@ -46,11 +47,16 @@ 3. 登录创建 session 时落库结构化客户端身份字段 4. 会话列表返回多端识别所需字段,并兼容旧 `clientLabel` -本阶段明确不包含: +`2026-05-13` 起,本接口同时承担账号安全页的会话组读模型: -1. `/api/auth/sessions/:sessionId/revoke` -2. 前端完整消费全部新增字段 -3. SpacetimeDB reducer / view 正式读表 +1. 后端按“同设备 + 同 IP”聚合活跃 `refresh_session` +2. 前端只消费后端聚合结果,不自行推断合并 +3. `POST /api/auth/sessions/{sessionId}/revoke` 已纳入 Rust 实现,用于踢下线非当前会话 + +本阶段仍明确不包含: + +1. SpacetimeDB reducer / view 正式读表 +2. 登录方式、refresh token 轮换策略或账号安全页整体重设计 ## 5. 请求与响应 contract @@ -70,6 +76,8 @@ "sessions": [ { "sessionId": "usess_xxx", + "sessionIds": ["usess_xxx", "usess_yyy"], + "sessionCount": 2, "clientType": "web_browser", "clientRuntime": "chrome", "clientPlatform": "windows", @@ -90,9 +98,12 @@ 字段说明: -1. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致 -2. `clientRuntime`、`clientPlatform`、`deviceDisplayName` 是多端识别首版最小新增字段 -3. 小程序来源额外暴露 `miniProgramAppId`、`miniProgramEnv` +1. `sessionId` 是聚合组代表会话 ID;若组内包含当前 `sid`,代表 ID 必须使用当前会话 ID +2. `sessionIds` 是该聚合组内全部活跃 session ID,前端批量踢下线时逐个调用 revoke +3. `sessionCount` 是聚合组内 session 数量 +4. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致 +5. `clientRuntime`、`clientPlatform`、`deviceDisplayName` 是多端识别首版最小新增字段 +6. 小程序来源额外暴露 `miniProgramAppId`、`miniProgramEnv` ### 5.3 失败响应 @@ -110,12 +121,25 @@ 1. 从 refresh cookie 读取当前原始 refresh token 2. 在 Axum 侧计算 `sha256(refresh_token)` 3. 与会话列表中的 `refresh_token_hash` 比较 -4. 命中则 `isCurrent = true` +4. 同时读取 Bearer access token claims 中的 `sid` +5. 聚合组内任意 session 命中当前 refresh hash 或当前 `sid`,则整组 `isCurrent = true` 说明: 1. 如果请求没有携带 refresh cookie,本接口仍可返回会话列表 -2. 此时全部会话的 `isCurrent` 都为 `false` +2. 此时仍可通过 Bearer `sid` 标记当前组 +3. 当前组不允许在前端显示“踢下线”,当前设备退出必须走 `/api/auth/logout` + +## 6.1 会话组合并规则 + +同设备同 IP 的 active refresh sessions 在后端合并为一条 DTO: + +1. 优先使用 `device_fingerprint + ip` 作为聚合 key +2. 无 `device_fingerprint` 时退化为 `client_type + client_runtime + client_platform + device_display_name + user_agent + ip` +3. `createdAt` 取组内最早 `created_at` +4. `lastSeenAt` 取组内最新 `last_seen_at` +5. `expiresAt` 取组内最新 `expires_at` +6. `ipMasked` 仍只返回脱敏 IP ## 7. 多端标识派生规则 @@ -161,8 +185,21 @@ 负责: 1. 读取 Bearer JWT 与 refresh cookie -2. 把活跃会话映射成旧接口兼容 DTO -3. 派生 `ipMasked` 与 `isCurrent` +2. 按同设备同 IP 聚合活跃会话 +3. 把活跃会话组映射成旧接口兼容 DTO +4. 派生 `ipMasked` 与 `isCurrent` +5. 暴露 `POST /api/auth/sessions/{sessionId}/revoke` + +## 8.3 指定会话吊销接口 + +`POST /api/auth/sessions/{sessionId}/revoke` 固定规则: + +1. Bearer JWT 必填 +2. 仅允许吊销当前用户自己的非当前会话 +3. 当前会话自吊销返回业务错误,提示使用退出登录 +4. 只撤销目标 `refresh_session`,不递增 `token_version` +5. 撤销后同步 auth store 到 SpacetimeDB +6. 认证中间件会校验 access token `sid` 对应 active `refresh_session`,因此被踢设备已有 access token 会立即失效 ## 9. 测试策略 @@ -172,6 +209,9 @@ 2. 微信内 H5 登录后,会话列表返回 `wechat_h5 + wechat_embedded_browser` 3. 显式小程序头优先于 `User-Agent` 判断 4. 请求携带当前 refresh cookie 时,只有当前会话 `isCurrent = true` +5. 同设备同 IP 会话会合并,并返回 `sessionIds/sessionCount` +6. 合并组包含当前 `sid` 或当前 refresh hash 时,整组 `isCurrent = true` +7. 指定远端会话吊销后,被踢设备 access token 立即无法通过认证 ## 10. 完成定义 @@ -181,4 +221,6 @@ 2. 会话列表可区分普通浏览器、微信内 H5、小程序来源 3. 同设备不同浏览器可在会话列表中清晰区分 4. `clientLabel` 与新增多端字段都已稳定返回 -5. 文档、任务清单与测试已同步更新 +5. 同设备同 IP 的重复 active refresh sessions 已合并展示 +6. 非当前会话可通过真实 revoke 接口踢下线 +7. 文档、任务清单与测试已同步更新 diff --git a/docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md b/docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md index 14cb7ec2..2a8fd4ed 100644 --- a/docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md +++ b/docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md @@ -94,6 +94,7 @@ API Server 新增统一 helper: | `auth_phone_login_success` | `POST /api/auth/phone/login` | | `auth_me_view` | `GET /api/auth/me` | | `auth_sessions_view` | `GET /api/auth/sessions` | +| `auth_revoke_session` | `POST /api/auth/sessions/{session_id}/revoke` | | `auth_refresh_success` | `POST /api/auth/refresh` | | `auth_logout` | `POST /api/auth/logout` | | `auth_logout_all` | `POST /api/auth/logout-all` | diff --git a/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md index 72833e8a..226ba63d 100644 --- a/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md +++ b/docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md @@ -32,7 +32,8 @@ 2. 请求字段:`currentPassword`、`newPassword`。 3. 若账号未设置过密码,允许 `currentPassword` 为空并设置首个密码。 4. 若账号已有密码,必须校验 `currentPassword` 后才能写入 `newPassword`。 -5. 修改成功后递增用户 `token_version`,使旧 access token 失效;前端沿用当前 refresh 会话刷新登录态。 +5. 修改成功后递增用户 `token_version`,使旧 access token 失效。 +6. `2026-05-13` 起,修改密码成功后必须撤销该用户全部 active `refresh_session`,并在响应中清除当前 refresh cookie;前端清空本地 access token 并回到未登录态。用户需要使用新密码重新登录。 ### 2.3 重置密码 @@ -79,7 +80,7 @@ ## 5. 2026-05-12 快照同步修复 -重置密码和修改密码都会改变认证真相:`password_hash`、`password_login_enabled`、`token_version`,重置密码还会立即创建新的 refresh session。因此 API 层在 `POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后,必须和密码登录、手机号登录、刷新、退出一样调用 `sync_auth_store_snapshot_to_spacetime()`。 +重置密码和修改密码都会改变认证真相:`password_hash`、`password_login_enabled`、`token_version`,重置密码还会立即创建新的 refresh session,修改密码还会撤销全部旧 refresh session。因此 API 层在 `POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后,必须和密码登录、手机号登录、刷新、退出一样调用 `sync_auth_store_snapshot_to_spacetime()`。 若只更新本地 `InMemoryAuthStore` 而不同步 SpacetimeDB 认证快照,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态,表现为用户已通过忘记密码重设成功,但再次密码登录仍返回“手机号或密码错误”。启动恢复时应从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照;当本地文件更新且远端表无更新时间戳时,优先使用本地文件并尝试回写 SpacetimeDB,避免旧远端状态覆盖刚重设的密码。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 39f070fb..3ac46d88 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -175,7 +175,7 @@ - [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md):`/api/auth/login-options` 首版设计,冻结登录方式列表 contract、配置开关来源与返回顺序。 - [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md):`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract,以及用户不存在时的 `401` 语义。 - [AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](./AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md):`/api/auth/logout-all` 全端登出设计,冻结全部 refresh session 吊销、`token_version` 递增、清 cookie 语义与 Rust 首版接口边界。 -- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。 +- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、同设备同 IP 会话组合并、`clientLabel` 兼容策略与 Rust 接口边界。 - [PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md):手机号验证码登录最小闭环设计,冻结 mock 验证码规则、`send-code` / `phone/login` contract 与 crate 边界。 - [PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md):手机号验证码冷却与失败次数限制设计,冻结同手机号同场景发送冷却、错误次数耗尽、`429` 与 `Retry-After` contract。 - [PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](./PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md):冻结 Rust `api-server + module-auth + platform-auth` 接入真实阿里云短信 provider 的 crate 边界、发送与校验职责、配置项和错误语义。 @@ -207,7 +207,7 @@ - [SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md):`M2` 第六张短信鉴权统计表 `sms_auth_event` 的事件范围、统计口径、索引与和风控/审计表的协作边界。 - [SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md):`M2` 第五张风控状态表 `auth_risk_block` 的作用域、活跃态、刷新/解除规则与读取派生约束。 - [SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md):`M2` 第四张鉴权审计表 `auth_audit_log` 的事件范围、追加写规则、索引与对外 DTO 派生约束。 -- [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md):`M2` 第三张会话表 `refresh_session` 的 cookie/hash 边界、轮换与吊销语义、索引与迁移规则。 +- [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md):`M2` 第三张会话表 `refresh_session` 的 cookie/hash 边界、轮换、logout fallback、指定会话吊销语义、索引与迁移规则。 - [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md):`M2` 第二张身份表 `auth_identity` 的 provider 范围、唯一约束、手机号/微信身份写入规则与迁移策略。 - [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md):`M2` 第一张身份主表 `user_account` 的职责边界、字段、唯一约束、状态迁移、旧 `users` 映射与落地约束。 - [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于旧 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。 diff --git a/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md b/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md index 4a8df71a..7e3a8322 100644 --- a/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md +++ b/docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md @@ -1,6 +1,6 @@ # Rust API Server 路由索引(2026-04-23) -更新时间:`2026-05-01` +更新时间:`2026-05-13` > 2026-04-29 补充:本文件保留为迁移期路由快照。DDD G1 后续并行工作的契约冻结口径以 [`SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`](./SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md) 为准,尤其是新增的 Big Fish、Puzzle、profile、runtime chat、story facade 和兼容路由删除计划。 > @@ -20,7 +20,7 @@ 2. 内部鉴权调试接口:`2` 条。 3. AI task 接口:`9` 条。 4. assets / OSS 接口:`15` 条。 -5. auth 接口:`12` 条。 +5. auth 接口:`13` 条。 6. custom world / agent 接口:`23` 条。 7. match3d creation / runtime 接口:`14` 条。 8. llm proxy 接口:`1` 条。 @@ -84,13 +84,14 @@ 3. `POST /api/auth/logout` 4. `POST /api/auth/logout-all` 5. `GET /api/auth/sessions` -6. `POST /api/auth/refresh` -7. `POST /api/auth/phone/send-code` -8. `POST /api/auth/phone/login` -9. `GET /api/auth/wechat/start` -10. `GET /api/auth/wechat/callback` -11. `POST /api/auth/wechat/bind-phone` -12. `POST /api/auth/entry` +6. `POST /api/auth/sessions/{session_id}/revoke` +7. `POST /api/auth/refresh` +8. `POST /api/auth/phone/send-code` +9. `POST /api/auth/phone/login` +10. `GET /api/auth/wechat/start` +11. `GET /api/auth/wechat/callback` +12. `POST /api/auth/wechat/bind-phone` +13. `POST /api/auth/entry` ### 3.6 Custom World / Agent diff --git a/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md b/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md index 652f92aa..3bd7d917 100644 --- a/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md +++ b/docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md @@ -31,7 +31,7 @@ G1 单 owner 文件范围: | 管理兑换码 | `POST /admin/api/profile/redeem-codes`、`POST /admin/api/profile/redeem-codes/disable` | 收敛 | 继续走 admin 路由,DTO 归入 profile/runtime 管理命令组 | WP-RT、WP-API | | 内部鉴权调试 | `GET /_internal/auth/claims`、`GET /_internal/auth/refresh-cookie` | 删除 | 只允许本地诊断脚本或 admin debug 能力使用,不作为前端契约 | WP-DEL | | 鉴权公开查询 | `GET /api/auth/login-options`、`GET /api/auth/public-users/by-code/{code}`、`GET /api/auth/public-users/by-id/{user_id}` | 保留 | `AuthLoginOptionsResponse`、`PublicUserSearchResponse` | WP-A | -| 鉴权会话 | `GET /api/auth/me`、`GET /api/auth/sessions`、`POST /api/auth/refresh`、`POST /api/auth/logout`、`POST /api/auth/logout-all` | 保留 | `AuthMeResponse`、`AuthSessionsResponse`、`RefreshSessionResponse`、`LogoutResponse`、`LogoutAllResponse` | WP-A | +| 鉴权会话 | `GET /api/auth/me`、`GET /api/auth/sessions`、`POST /api/auth/sessions/{session_id}/revoke`、`POST /api/auth/refresh`、`POST /api/auth/logout`、`POST /api/auth/logout-all` | 保留 | `AuthMeResponse`、`AuthSessionsResponse`、`RevokeAuthSessionResponse`、`RefreshSessionResponse`、`LogoutResponse`、`LogoutAllResponse` | WP-A | | 鉴权登录 | `POST /api/auth/phone/send-code`、`POST /api/auth/phone/login`、`GET /api/auth/wechat/start`、`GET /api/auth/wechat/callback`、`POST /api/auth/wechat/bind-phone`、`POST /api/auth/entry`、`POST /api/auth/password/change`、`POST /api/auth/password/reset` | 保留 | TS 命名统一使用 `Auth*` 前缀,Rust 命名维持领域语义 | WP-A | | 旧本地生成资产代理 | `GET /generated-character-drafts/{*path}`、`/generated-characters/{*path}`、`/generated-animations/{*path}`、`/generated-big-fish-assets/{*path}`、`/generated-puzzle-assets/{*path}`、`/generated-custom-world-scenes/{*path}`、`/generated-custom-world-covers/{*path}`、`/generated-qwen-sprites/{*path}` | 已删除 | 正式读取统一走 `GET /api/assets/read-url` 或 asset object projection;`/generated-*` 仅允许作为 legacyPublicPath/object key 标识,不再作为可裸读路由 | WP-AS、WP-FE、WP-DEL | | LLM 代理 | `POST /api/llm/chat/completions` | 收敛 | 仅作为平台能力代理;玩法 prompt 不允许由前端直接传入 | WP-PF、WP-API | @@ -59,7 +59,7 @@ G1 单 owner 文件范围: | --- | --- | | `shared-contracts/src/api.rs` | `ApiResponseMeta`、`ApiErrorPayload`、`ApiSuccessEnvelope`、`ApiErrorEnvelope` | | `shared-contracts/src/admin.rs` | `AdminLoginRequest/Response`、`AdminSessionPayload`、`AdminMeResponse`、`AdminOverviewResponse`、`AdminDebugHttpRequest/Response` | -| `shared-contracts/src/auth.rs` | `AuthLoginOptionsResponse`、`AuthUserPayload`、`PublicUserSummaryPayload`、`PublicUserSearchResponse`、`PasswordEntry*`、`PasswordChange*`、`PasswordReset*`、`AuthMeResponse`、`AuthSessionsResponse`、`RefreshSessionResponse`、`Logout*`、`Phone*`、`Wechat*` | +| `shared-contracts/src/auth.rs` | `AuthLoginOptionsResponse`、`AuthUserPayload`、`PublicUserSummaryPayload`、`PublicUserSearchResponse`、`PasswordEntry*`、`PasswordChange*`、`PasswordReset*`、`AuthMeResponse`、`AuthSessionsResponse`、`RevokeAuthSessionResponse`、`RefreshSessionResponse`、`Logout*`、`Phone*`、`Wechat*` | | `shared-contracts/src/ai.rs` | `CreateAiTaskRequest`、`AppendAiTextChunkRequest`、`CompleteAiStageRequest`、`AttachAiResultReferenceRequest`、`FailAiTaskRequest`、`AiTask*Payload`、`AiTaskMutationResponse`、`AiTaskAcceptedResponse` | | `shared-contracts/src/assets.rs` | Direct upload、read url、asset object、asset binding、asset history、character visual/animation、workflow cache、role asset workflow 相关 DTO | | `shared-contracts/src/creation_agent_document_input.rs` | `ParseCreationAgentDocumentInputRequest/Response`、`CreationAgentDocumentInputPayload` | diff --git a/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md index a65a9465..bcce634e 100644 --- a/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md +++ b/docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md @@ -115,6 +115,8 @@ 1. 从 cookie 读出原始 refresh token 2. 计算 hash 3. 与 `refresh_session.refresh_token_hash` 比较 +4. 若 refresh cookie 缺失或不可用,再使用 Bearer access token claims 中的 `sid` 与 `refresh_session.session_id` 比较 +5. 会话列表按“同设备 + 同 IP”聚合时,组内任一 session 命中当前 hash 或当前 `sid`,整组都视为当前设备组 ## 5. 表访问级别 @@ -228,9 +230,10 @@ 写入规则: 1. 按当前 cookie 找 session -2. 写 `revoked_at = now` -3. 写 `revoked_reason_code = logout` -4. 同时提升 `user_account.token_version` +2. 如果 refresh cookie 缺失,则回退用 Bearer access token claims 中的 `sid` 找当前 session +3. 写 `revoked_at = now` +4. 写 `revoked_reason_code = logout` +5. 同时提升 `user_account.token_version` ### 8.4 吊销全部会话 @@ -248,7 +251,7 @@ 触发点: -1. `POST /api/auth/sessions/:sessionId/revoke` +1. `POST /api/auth/sessions/{sessionId}/revoke` 写入规则: @@ -257,6 +260,13 @@ 3. 只改目标 `refresh_session` 4. `revoked_reason_code = session_revoke` 5. 不提升 `token_version` +6. 撤销后必须同步 auth store 到 SpacetimeDB + +读取约束: + +1. Bearer JWT 中的 `sid` 必须对应 active `refresh_session` +2. 被该接口撤销的设备即使 access token 未过期,后续请求也必须立刻返回未授权 +3. 该接口不承担当前设备退出语义;当前设备退出固定走 `/api/auth/logout` ### 8.6 账号被禁用或并入 @@ -315,13 +325,18 @@ 1. `clientLabel` 当前阶段继续兼容保留,但固定与 `deviceDisplayName` 对齐。 2. `ipMasked`、`isCurrent` 继续在 Axum 侧派生。 +3. 同设备同 IP 的 active sessions 由 Axum 聚合后返回一条记录。 +4. `sessionId` 是代表 ID;当前组代表 ID 使用当前 `sid` 对应 session。 +5. `sessionIds` 返回组内全部 active session ID,`sessionCount` 返回组内数量。 +6. 聚合组时间语义:`createdAt` 取最早创建时间,`lastSeenAt` 与 `expiresAt` 取最新值。 ### 10.3 `POST /api/auth/logout` 依赖: 1. 当前 cookie 命中的 `refresh_session` -2. `user_account.token_version` +2. cookie 缺失时 Bearer `sid` 命中的 `refresh_session` +3. `user_account.token_version` ### 10.4 `POST /api/auth/logout-all` @@ -330,6 +345,22 @@ 1. 当前 `user_id` 下全部活跃 `refresh_session` 2. `user_account.token_version` +### 10.5 `POST /api/auth/sessions/{sessionId}/revoke` + +依赖: + +1. 当前 Bearer JWT 的 `user_id` +2. 当前 Bearer JWT 的 `sid` +3. 目标 `refresh_session.session_id` +4. `refresh_session.revoked_at` +5. `refresh_session.expires_at` + +固定行为: + +1. 目标 session 必须属于当前用户 +2. 目标 session 不能是当前 `sid` +3. 成功只撤销目标 session,不递增 `token_version` + ## 11. 与当前 Node `user_sessions` 的映射关系 | Node `user_sessions` 列 | 新 `refresh_session` 字段 | 迁移规则 | diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 2fffab28..a6c38a51 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -150,6 +150,8 @@ export type AuthRefreshResponse = { export type AuthSessionSummary = { sessionId: string; + sessionIds: string[]; + sessionCount: number; clientType: string; clientRuntime: string; clientPlatform: string; diff --git a/server-rs/crates/api-server/src/ai_tasks.rs b/server-rs/crates/api-server/src/ai_tasks.rs index d90c0be7..42a446df 100644 --- a/server-rs/crates/api-server/src/ai_tasks.rs +++ b/server-rs/crates/api-server/src/ai_tasks.rs @@ -776,7 +776,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_ai_tasks".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_ai_tasks"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 6df6090f..15052774 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -33,7 +33,7 @@ use crate::{ }, auth_me::auth_me, 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::{ 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, @@ -331,6 +331,13 @@ pub fn build_router(state: AppState) -> Router { 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( "/api/profile/me", axum::routing::patch(update_profile_identity).route_layer( @@ -1921,10 +1928,12 @@ mod tests { user: &module_auth::AuthUser, session_id: &str, ) -> 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( AccessTokenClaimsInput { user_id: user.id.clone(), - session_id: session_id.to_string(), + session_id: active_session_id, provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: user.token_version, @@ -1933,13 +1942,22 @@ mod tests { display_name: Some(user.display_name.clone()), }, state.auth_jwt_config(), - OffsetDateTime::now_utc(), + now, ) .expect("claims should build"); 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( app: Router, phone_number: &str, @@ -1963,6 +1981,37 @@ mod tests { .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 { let mut config = AppConfig::default(); config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string()); @@ -2536,10 +2585,11 @@ mod tests { let config = AppConfig::default(); let state = AppState::new(config.clone()).expect("state should build"); 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( AccessTokenClaimsInput { user_id: seed_user.id.clone(), - session_id: "sess_auth_debug".to_string(), + session_id: session_id.clone(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: seed_user.token_version, @@ -2577,10 +2627,7 @@ mod tests { 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"]["sid"], - Value::String("sess_auth_debug".to_string()) - ); + assert_eq!(payload["claims"]["sid"], Value::String(session_id)); assert_eq!( payload["claims"]["ver"], 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["clientRuntime"] == Value::String("chrome".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["isCurrent"] == Value::Bool(true) })); assert!(sessions.iter().any(|session| { session["clientType"] == Value::String("mini_program".to_string()) && session["clientRuntime"] == Value::String("wechat_mini_program".to_string()) + && session["sessionCount"] == Value::Number(1.into()) && session["miniProgramAppId"] == Value::String("wx-session-test".to_string()) && session["miniProgramEnv"] == Value::String("release".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] async fn password_entry_reuses_same_user_for_same_phone() { let state = AppState::new(AppConfig::default()).expect("state should build"); @@ -4362,9 +4516,23 @@ mod tests { #[tokio::test] async fn password_change_allows_login_with_new_password_only() { let state = AppState::new(AppConfig::default()).expect("state should build"); - let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await; - let token = sign_test_user_token(&state, &seed_user, "sess_password_change"); + seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await; 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 .clone() @@ -4386,6 +4554,40 @@ mod tests { .await .expect("change password request should succeed"); 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 = 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 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 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 .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] async fn logout_clears_cookie_and_invalidates_current_access_token() { let state = AppState::new(AppConfig::default()).expect("state should build"); @@ -4688,6 +5018,12 @@ mod tests { let login_response = 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 .into_body() .collect() @@ -4702,6 +5038,7 @@ mod tests { .to_string(); let logout_response = app + .clone() .oneshot( Request::builder() .method("POST") @@ -4721,6 +5058,19 @@ mod tests { .and_then(|value| value.to_str().ok()) .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] diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index 78c2e815..c6a9e789 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -117,6 +117,34 @@ pub async fn require_bearer_auth( .with_message("当前登录态已失效,请重新登录")); } + let session_is_active = state + .refresh_session_service() + .is_session_active_for_user( + claims.user_id(), + claims.session_id(), + OffsetDateTime::now_utc(), + ) + .map_err(|error| { + warn!( + %request_id, + user_id = %claims.user_id(), + session_id = %claims.session_id(), + error = %error, + "Bearer JWT refresh session 状态读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + if !session_is_active { + warn!( + %request_id, + user_id = %claims.user_id(), + session_id = %claims.session_id(), + "Bearer JWT 对应 refresh session 已失效" + ); + return Err(AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("当前登录态已失效,请重新登录")); + } + request .extensions_mut() .insert(AuthenticatedAccessToken::new(claims.clone())); diff --git a/server-rs/crates/api-server/src/auth_sessions.rs b/server-rs/crates/api-server/src/auth_sessions.rs index b9c0b716..de9c70e9 100644 --- a/server-rs/crates/api-server/src/auth_sessions.rs +++ b/server-rs/crates/api-server/src/auth_sessions.rs @@ -1,10 +1,15 @@ +use std::collections::HashMap; + use axum::{ Json, - extract::{Extension, State}, + extract::{Extension, Path, State}, http::StatusCode, }; +use module_auth::{RefreshSessionRecord, RevokeRefreshSessionByUserInput}; use platform_auth::hash_refresh_session_token; -use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse}; +use shared_contracts::auth::{ + AuthSessionSummaryPayload, AuthSessionsResponse, RevokeAuthSessionResponse, +}; use time::OffsetDateTime; use crate::{ @@ -37,41 +42,189 @@ pub async fn auth_sessions( .refresh_session_service() .list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc()) .map_err(map_refresh_session_list_error)?; + let current_session_id = authenticated.claims().session_id().to_string(); + let session_groups = group_sessions_by_device_and_ip(sessions.sessions); Ok(json_success_body( Some(&request_context), AuthSessionsResponse { - sessions: sessions - .sessions + sessions: session_groups .into_iter() - .map(|session| { - let is_current = current_refresh_token_hash - .as_ref() - .is_some_and(|hash| session.refresh_token_hash == *hash); - let client_label = session.client_info.device_display_name.clone(); - - AuthSessionSummaryPayload { - session_id: session.session_id, - client_type: session.client_info.client_type, - client_runtime: session.client_info.client_runtime, - client_platform: session.client_info.client_platform, - client_label, - device_display_name: session.client_info.device_display_name, - mini_program_app_id: session.client_info.mini_program_app_id, - mini_program_env: session.client_info.mini_program_env, - user_agent: session.client_info.user_agent, - ip_masked: mask_ip(session.client_info.ip.as_deref()), - is_current, - created_at: session.created_at, - last_seen_at: session.last_seen_at, - expires_at: session.expires_at, - } + .map(|group| { + build_session_summary( + group, + current_refresh_token_hash.as_deref(), + ¤t_session_id, + ) }) .collect(), }, )) } +pub async fn revoke_auth_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(session_id): Path, +) -> Result, 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, +) -> Vec> { + let mut grouped = HashMap::>::new(); + for session in sessions { + grouped + .entry(build_session_group_key(&session)) + .or_default() + .push(session); + } + + let mut groups = grouped.into_values().collect::>(); + 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, + 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::>(); + let session_count = u32::try_from(session_ids.len()).unwrap_or(u32::MAX); + + AuthSessionSummaryPayload { + session_id: representative.session_id.clone(), + session_ids, + session_count, + client_type: representative.client_info.client_type.clone(), + client_runtime: representative.client_info.client_runtime.clone(), + client_platform: representative.client_info.client_platform.clone(), + client_label, + device_display_name: representative.client_info.device_display_name.clone(), + mini_program_app_id: representative.client_info.mini_program_app_id.clone(), + mini_program_env: representative.client_info.mini_program_env.clone(), + user_agent: representative.client_info.user_agent.clone(), + ip_masked: mask_ip(representative.client_info.ip.as_deref()), + is_current, + created_at: group_earliest_created(&group).to_string(), + last_seen_at: group_latest_last_seen(&group).to_string(), + expires_at: group_latest_expires_at(&group).to_string(), + } +} + +fn group_latest_last_seen(group: &[RefreshSessionRecord]) -> &str { + group + .iter() + .map(|session| session.last_seen_at.as_str()) + .max() + .unwrap_or("") +} + +fn group_earliest_created(group: &[RefreshSessionRecord]) -> &str { + group + .iter() + .map(|session| session.created_at.as_str()) + .min() + .unwrap_or("") +} + +fn group_latest_expires_at(group: &[RefreshSessionRecord]) -> &str { + group + .iter() + .map(|session| session.expires_at.as_str()) + .max() + .unwrap_or("") +} + fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError { match error { module_auth::RefreshSessionError::UserNotFound => { @@ -88,3 +241,19 @@ fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> Ap } } } + +fn map_refresh_session_revoke_error(error: module_auth::RefreshSessionError) -> AppError { + match error { + module_auth::RefreshSessionError::MissingToken + | module_auth::RefreshSessionError::SessionNotFound => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } + module_auth::RefreshSessionError::SessionExpired + | module_auth::RefreshSessionError::UserNotFound => { + AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string()) + } + module_auth::RefreshSessionError::Store(message) => { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message) + } + } +} diff --git a/server-rs/crates/api-server/src/creation_agent_document_input.rs b/server-rs/crates/api-server/src/creation_agent_document_input.rs index 43ccc5d9..46bf0976 100644 --- a/server-rs/crates/api-server/src/creation_agent_document_input.rs +++ b/server-rs/crates/api-server/src/creation_agent_document_input.rs @@ -375,14 +375,15 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: user.id, - session_id: "sess_creation_doc_input".to_string(), + user_id: user.id.clone(), + session_id: state + .seed_test_refresh_session_for_user(&user, "sess_creation_doc_input"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: user.token_version, phone_verified: true, binding_status: BindingStatus::Active, - display_name: Some(user.display_name), + display_name: Some(user.display_name.clone()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), diff --git a/server-rs/crates/api-server/src/llm.rs b/server-rs/crates/api-server/src/llm.rs index c4944c6b..072c7fad 100644 --- a/server-rs/crates/api-server/src/llm.rs +++ b/server-rs/crates/api-server/src/llm.rs @@ -333,7 +333,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_llm_proxy".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_llm_proxy"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/logout.rs b/server-rs/crates/api-server/src/logout.rs index 98866782..2e4a4f05 100644 --- a/server-rs/crates/api-server/src/logout.rs +++ b/server-rs/crates/api-server/src/logout.rs @@ -40,6 +40,7 @@ pub async fn logout( LogoutCurrentSessionInput { user_id: authenticated.claims().user_id().to_string(), refresh_token_hash, + session_id: Some(authenticated.claims().session_id().to_string()), }, OffsetDateTime::now_utc(), ) diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 635a517d..9d305c68 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -15,7 +15,8 @@ use crate::{ auth::AuthenticatedAccessToken, auth_payload::map_auth_user_payload, auth_session::{ - attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session, + attach_set_cookie_header, build_clear_refresh_session_cookie_header, + build_refresh_session_cookie_header, create_auth_session, record_daily_login_tracking_event_after_auth_success, }, http_error::AppError, @@ -30,14 +31,17 @@ pub async fn change_password( Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, -) -> Result, AppError> { +) -> Result { let result = state .password_entry_service() - .change_password(ChangePasswordInput { - user_id: authenticated.claims().user_id().to_string(), - current_password: payload.current_password, - new_password: payload.new_password, - }) + .change_password_and_revoke_all_sessions( + ChangePasswordInput { + user_id: authenticated.claims().user_id().to_string(), + current_password: payload.current_password, + new_password: payload.new_password, + }, + OffsetDateTime::now_utc(), + ) .await .map_err(map_password_management_error)?; state @@ -48,11 +52,20 @@ pub async fn change_password( .with_message(format!("同步认证快照失败:{error}")) })?; - Ok(json_success_body( - Some(&request_context), - PasswordChangeResponse { - user: map_auth_user_payload(result.user), - }, + let mut headers = HeaderMap::new(); + attach_set_cookie_header( + &mut headers, + build_clear_refresh_session_cookie_header(&state)?, + ); + + Ok(( + headers, + json_success_body( + Some(&request_context), + PasswordChangeResponse { + user: map_auth_user_payload(result.user), + }, + ), )) } diff --git a/server-rs/crates/api-server/src/runtime_browse_history.rs b/server-rs/crates/api-server/src/runtime_browse_history.rs index 1bf5dc0b..7981ad82 100644 --- a/server-rs/crates/api-server/src/runtime_browse_history.rs +++ b/server-rs/crates/api-server/src/runtime_browse_history.rs @@ -374,7 +374,10 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_runtime_browse_history".to_string(), + session_id: state.seed_test_refresh_session_for_user_id( + "user_00000001", + "sess_runtime_browse_history", + ), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/runtime_inventory.rs b/server-rs/crates/api-server/src/runtime_inventory.rs index 456ed427..53c0e29d 100644 --- a/server-rs/crates/api-server/src/runtime_inventory.rs +++ b/server-rs/crates/api-server/src/runtime_inventory.rs @@ -174,7 +174,10 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_runtime_inventory".to_string(), + session_id: state.seed_test_refresh_session_for_user_id( + "user_00000001", + "sess_runtime_inventory", + ), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 8c1434de..8d0afcd9 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1568,7 +1568,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_runtime_profile".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/runtime_save.rs b/server-rs/crates/api-server/src/runtime_save.rs index 85a50621..3b02de8f 100644 --- a/server-rs/crates/api-server/src/runtime_save.rs +++ b/server-rs/crates/api-server/src/runtime_save.rs @@ -575,7 +575,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_runtime_save".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_save"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/runtime_settings.rs b/server-rs/crates/api-server/src/runtime_settings.rs index bb9337b3..8535f692 100644 --- a/server-rs/crates/api-server/src/runtime_settings.rs +++ b/server-rs/crates/api-server/src/runtime_settings.rs @@ -350,7 +350,10 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_runtime_settings".to_string(), + session_id: state.seed_test_refresh_session_for_user_id( + "user_00000001", + "sess_runtime_settings", + ), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 60ada394..8b5079a3 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -600,6 +600,54 @@ impl AppState { #[cfg(test)] impl AppState { + pub(crate) fn seed_test_refresh_session_for_user( + &self, + user: &module_auth::AuthUser, + seed: &str, + ) -> String { + let session = self + .refresh_session_service() + .create_session( + module_auth::CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: platform_auth::hash_refresh_session_token(&format!( + "test-refresh-token-{seed}" + )), + issued_by_provider: module_auth::AuthLoginMethod::Password, + client_info: module_auth::RefreshSessionClientInfo { + client_type: "web_browser".to_string(), + client_runtime: "test".to_string(), + client_platform: "test".to_string(), + client_instance_id: Some(seed.to_string()), + device_fingerprint: Some(format!("test-device-{seed}")), + device_display_name: "Test Browser".to_string(), + mini_program_app_id: None, + mini_program_env: None, + user_agent: Some("GenarrativeApiServerTest/1.0".to_string()), + ip: Some("127.0.0.1".to_string()), + }, + }, + OffsetDateTime::now_utc(), + ) + .expect("test refresh session should create"); + + session.session.session_id + } + + pub(crate) fn seed_test_refresh_session_for_user_id( + &self, + user_id: &str, + seed: &str, + ) -> String { + let user = self + .auth_user_service() + .get_user_by_id(user_id) + .expect("test user lookup should succeed") + .expect("test user should exist"); + + self.seed_test_refresh_session_for_user(&user, seed) + } + fn cache_test_creation_entry_config(&self, config: CreationEntryConfigResponse) { *self .test_creation_entry_config diff --git a/server-rs/crates/api-server/src/story_battles.rs b/server-rs/crates/api-server/src/story_battles.rs index 35ea4c8e..2ac27318 100644 --- a/server-rs/crates/api-server/src/story_battles.rs +++ b/server-rs/crates/api-server/src/story_battles.rs @@ -959,7 +959,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_story_battles".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_story_battles"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/story_sessions.rs b/server-rs/crates/api-server/src/story_sessions.rs index 3caeb625..0188301d 100644 --- a/server-rs/crates/api-server/src/story_sessions.rs +++ b/server-rs/crates/api-server/src/story_sessions.rs @@ -1132,7 +1132,8 @@ mod tests { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), - session_id: "sess_story_sessions".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", "sess_story_sessions"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index 65bc6888..0f3aad21 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -121,6 +121,9 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option { 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") => { Some(route_spec("auth_refresh_success", "auth", Site, "site")) } diff --git a/server-rs/crates/module-auth/src/application.rs b/server-rs/crates/module-auth/src/application.rs index 032fb9c6..132b9995 100644 --- a/server-rs/crates/module-auth/src/application.rs +++ b/server-rs/crates/module-auth/src/application.rs @@ -100,6 +100,12 @@ pub struct ListActiveRefreshSessionsResult { pub sessions: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RevokeRefreshSessionResult { + pub session_id: String, + pub revoked: bool, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogoutCurrentSessionResult { pub user: AuthUser, diff --git a/server-rs/crates/module-auth/src/commands.rs b/server-rs/crates/module-auth/src/commands.rs index da48cffb..d84ce3cf 100644 --- a/server-rs/crates/module-auth/src/commands.rs +++ b/server-rs/crates/module-auth/src/commands.rs @@ -87,10 +87,17 @@ pub struct RotateRefreshSessionInput { 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)] pub struct LogoutCurrentSessionInput { pub user_id: String, pub refresh_token_hash: Option, + pub session_id: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 6b1ac1e4..a855ab96 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -230,6 +230,22 @@ impl PasswordEntryService { pub async fn change_password( &self, input: ChangePasswordInput, + ) -> Result { + self.change_password_internal(input, None).await + } + + pub async fn change_password_and_revoke_all_sessions( + &self, + input: ChangePasswordInput, + now: OffsetDateTime, + ) -> Result { + self.change_password_internal(input, Some(now)).await + } + + async fn change_password_internal( + &self, + input: ChangePasswordInput, + revoke_all_sessions_at: Option, ) -> Result { validate_password(&input.new_password)?; let stored_user = self @@ -257,7 +273,7 @@ impl PasswordEntryService { .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; let user = self .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(ChangePasswordResult { user }) @@ -375,6 +391,39 @@ impl RefreshSessionService { let sessions = self.store.list_active_sessions_by_user(user_id, now)?; Ok(ListActiveRefreshSessionsResult { sessions }) } + + pub fn revoke_session_by_user_and_session( + &self, + input: RevokeRefreshSessionByUserInput, + now: OffsetDateTime, + ) -> Result { + 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 { + self.store + .is_session_active_for_user(user_id, session_id.trim(), now) + } } impl PhoneAuthService { @@ -779,7 +828,7 @@ impl AuthUserService { input: LogoutCurrentSessionInput, now: OffsetDateTime, ) -> Result { - if let Some(refresh_token_hash) = input + let revoked_by_hash = if let Some(refresh_token_hash) = input .refresh_token_hash .as_ref() .map(|value| value.trim()) @@ -788,6 +837,21 @@ impl AuthUserService { self.store .revoke_session_by_refresh_token_hash(refresh_token_hash, now) .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 @@ -1685,6 +1749,36 @@ impl InMemoryAuthStore { Ok(sessions) } + fn is_session_active_for_user( + &self, + user_id: &str, + session_id: &str, + now: OffsetDateTime, + ) -> Result { + 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( &self, session_id: &str, @@ -1774,6 +1868,37 @@ impl InMemoryAuthStore { Ok(()) } + fn revoke_session_by_user_and_session_id( + &self, + user_id: &str, + session_id: &str, + now: OffsetDateTime, + ) -> Result { + 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( &self, user_id: &str, @@ -1832,11 +1957,21 @@ impl InMemoryAuthStore { &self, user_id: &str, password_hash: String, + revoke_all_sessions_at: Option, ) -> Result, PasswordEntryError> { let mut state = self .inner .lock() .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() { if stored_user.user.id != user_id { @@ -1847,6 +1982,18 @@ impl InMemoryAuthStore { stored_user.password_login_enabled = true; stored_user.user.token_version += 1; 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)?; return Ok(Some(next_user)); } @@ -2177,6 +2324,118 @@ mod tests { 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] async fn password_entry_rejects_wrong_password_after_set() { let store = build_store(); @@ -2524,6 +2783,7 @@ mod tests { LogoutCurrentSessionInput { user_id: user.id.clone(), refresh_token_hash: Some(refresh_token_hash.clone()), + session_id: None, }, OffsetDateTime::now_utc(), ) @@ -2543,6 +2803,148 @@ mod tests { 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] async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() { let store = build_store(); diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 038133a4..4bc26c85 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -114,6 +114,8 @@ pub struct AuthSessionsResponse { #[serde(rename_all = "camelCase")] pub struct AuthSessionSummaryPayload { pub session_id: String, + pub session_ids: Vec, + pub session_count: u32, pub client_type: String, pub client_runtime: String, pub client_platform: String, @@ -144,6 +146,11 @@ pub struct LogoutAllResponse { pub ok: bool, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct RevokeAuthSessionResponse { + pub ok: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PhoneSendCodeRequest { diff --git a/src/components/auth/AccountModal.test.tsx b/src/components/auth/AccountModal.test.tsx index a505a005..3b7b2181 100644 --- a/src/components/auth/AccountModal.test.tsx +++ b/src/components/auth/AccountModal.test.tsx @@ -31,6 +31,8 @@ function renderAccountModal(overrides?: { riskBlocks?: AuthRiskBlockSummary[]; sessions?: AuthSessionSummary[]; auditLogs?: AuthAuditLogEntry[]; + onRevokeSession?: (session: AuthSessionSummary) => Promise; + revokingSessionIds?: string[]; initialSection?: | 'appearance' | 'account' @@ -63,7 +65,10 @@ function renderAccountModal(overrides?: { onRefreshSessions={vi.fn().mockResolvedValue(undefined)} onLogoutAll={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} onSendChangePhoneCode={vi.fn().mockResolvedValue({ cooldownSeconds: 60, @@ -75,6 +80,30 @@ function renderAccountModal(overrides?: { ); } +function buildSession( + overrides: Partial = {}, +): 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', () => { renderAccountModal(); @@ -238,8 +267,10 @@ test('account panel includes merged security devices and audit sections', async }, ], sessions: [ - { + buildSession({ sessionId: 'session-1', + sessionIds: ['session-1'], + sessionCount: 1, clientType: 'mobile', clientRuntime: 'ios', clientPlatform: 'wechat', @@ -253,7 +284,7 @@ test('account panel includes merged security devices and audit sections', async lastSeenAt: '2026-04-20T09:00:00.000Z', expiresAt: '2026-04-27T09:00:00.000Z', ipMasked: '10.0.*.*', - }, + }), ], 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(); }); + +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); +}); diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 5864c53b..6c346beb 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -40,7 +40,8 @@ type AccountModalProps = { onRefreshSessions: () => Promise; onLogoutAll: () => Promise; onRefreshAuditLogs: () => Promise; - onRevokeSession: (sessionId: string) => Promise; + onRevokeSession: (session: AuthSessionSummary) => Promise; + revokingSessionIds: string[]; changePhoneCaptchaChallenge: AuthCaptchaChallenge | null; onSendChangePhoneCode: ( phone: string, @@ -298,6 +299,7 @@ export function AccountModal({ onLogoutAll, onRefreshAuditLogs, onRevokeSession, + revokingSessionIds, changePhoneCaptchaChallenge, onSendChangePhoneCode, onChangePhone, @@ -759,41 +761,55 @@ export function AccountModal({ 正在读取当前登录设备... ) : sessions.length > 0 ? ( - sessions.map((session) => ( -
-
- {session.clientLabel} - - {session.isCurrent ? '当前设备' : '已登录'} - -
-
- 最近活跃:{formatSessionTime(session.lastSeenAt)} -
-
- 到期时间:{formatSessionTime(session.expiresAt)} -
- {session.ipMasked ? ( -
- IP:{session.ipMasked} + sessions.map((session) => { + const isRevoking = revokingSessionIds.includes( + session.sessionId, + ); + + return ( +
+
+ {session.clientLabel} +
+ {session.sessionCount > 1 ? ( + + {session.sessionCount} 个会话 + + ) : null} + + {session.isCurrent ? '当前设备' : '已登录'} + +
- ) : null} - {!session.isCurrent ? ( - - ) : null} -
- )) +
+ 最近活跃:{formatSessionTime(session.lastSeenAt)} +
+
+ 到期时间:{formatSessionTime(session.expiresAt)} +
+ {session.ipMasked ? ( +
+ IP:{session.ipMasked} +
+ ) : null} + {!session.isCurrent ? ( + + ) : null} +
+ ); + }) ) : (
暂无可展示的登录设备。 diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 10e8acb9..21db5870 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; 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 { AuthGate } from './AuthGate'; import { useAuthUi } from './AuthUiContext'; @@ -23,6 +23,10 @@ const authMocks = vi.hoisted(() => ({ logoutAuthUser: vi.fn(), redeemRegistrationInviteCode: vi.fn(), resetPassword: vi.fn(), + getAuthAuditLogs: vi.fn(), + getAuthRiskBlocks: vi.fn(), + getAuthSessions: vi.fn(), + revokeAuthSessions: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), consumeAuthCallbackResult: vi.fn(), @@ -42,11 +46,11 @@ vi.mock('../../services/authService', () => ({ changePhoneNumber: vi.fn(), consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult, getStoredLastLoginPhone: vi.fn(() => ''), - getAuthAuditLogs: vi.fn(), + getAuthAuditLogs: authMocks.getAuthAuditLogs, getAuthLoginOptions: authMocks.getAuthLoginOptions, - getAuthRiskBlocks: vi.fn(), + getAuthRiskBlocks: authMocks.getAuthRiskBlocks, getCurrentAuthUser: authMocks.getCurrentAuthUser, - getAuthSessions: vi.fn(), + getAuthSessions: authMocks.getAuthSessions, getCaptchaChallengeFromError: vi.fn(() => null), liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode, @@ -54,7 +58,7 @@ vi.mock('../../services/authService', () => ({ logoutAuthUser: authMocks.logoutAuthUser, redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode, resetPassword: authMocks.resetPassword, - revokeAuthSession: vi.fn(), + revokeAuthSessions: authMocks.revokeAuthSessions, sendPhoneLoginCode: authMocks.sendPhoneLoginCode, setStoredLastLoginPhone: vi.fn(), startWechatLogin: authMocks.startWechatLogin, @@ -73,9 +77,12 @@ vi.mock('../../hooks/useGameSettings', () => ({ }), })); -vi.mock('./AccountModal', () => ({ - AccountModal: () => null, -})); +vi.mock('./AccountModal', async () => { + const actual = + await vi.importActual('./AccountModal'); + + return actual; +}); vi.mock('./BindPhoneScreen', () => ({ BindPhoneScreen: () =>
绑定手机号
, @@ -116,6 +123,10 @@ beforeEach(() => { authMocks.changePassword.mockResolvedValue(mockUser); authMocks.logoutAllAuthSessions.mockResolvedValue(undefined); authMocks.logoutAuthUser.mockResolvedValue(undefined); + authMocks.getAuthAuditLogs.mockResolvedValue([]); + authMocks.getAuthRiskBlocks.mockResolvedValue([]); + authMocks.getAuthSessions.mockResolvedValue([]); + authMocks.revokeAuthSessions.mockResolvedValue(undefined); authMocks.redeemRegistrationInviteCode.mockResolvedValue({ center: { inviteCode: 'SY12345678', @@ -205,6 +216,21 @@ function LogoutStateProbe() { ); } +function AccountPanelProbe() { + const authUi = useAuthUi(); + + return ( + + ); +} + test('auth gate keeps platform content visible when phone login is available', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], @@ -786,3 +812,101 @@ test('auth gate separates sms and password login by tabs', async () => { 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( + + + , + ); + + 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( + +
+ + +
+
, + ); + + 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(); +}); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index a7db8e38..e4e12a61 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -38,7 +38,7 @@ import { logoutAuthUser, redeemRegistrationInviteCode, resetPassword, - revokeAuthSession, + revokeAuthSessions, sendPhoneLoginCode, setStoredLastLoginPhone, startWechatLogin, @@ -121,6 +121,7 @@ export function AuthGate({ children }: AuthGateProps) { useState(null); const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); + const [revokingSessionIds, setRevokingSessionIds] = useState([]); const [auditLogs, setAuditLogs] = useState([]); const [loadingAuditLogs, setLoadingAuditLogs] = useState(false); const [riskBlocks, setRiskBlocks] = useState([]); @@ -167,6 +168,7 @@ export function AuthGate({ children }: AuthGateProps) { setSettingsEntryMode('settings'); setInitialSettingsSection(null); setSessions([]); + setRevokingSessionIds([]); setAuditLogs([]); setRiskBlocks([]); setLoginCaptchaChallenge(null); @@ -691,6 +693,7 @@ export function AuthGate({ children }: AuthGateProps) { loadingRiskBlocks={loadingRiskBlocks} loadingSessions={loadingSessions} loadingAuditLogs={loadingAuditLogs} + revokingSessionIds={revokingSessionIds} isHydratingSettings={settings.isHydratingSettings} isPersistingSettings={settings.isPersistingSettings} settingsError={settings.settingsError} @@ -752,14 +755,17 @@ export function AuthGate({ children }: AuthGateProps) { 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 { - await revokeAuthSession(sessionId); - setSessions((current) => - current.filter( - (session) => session.sessionId !== sessionId, - ), - ); + await revokeAuthSessions(sessionIds); + setSessions(await getAuthSessions()); setAuditLogs(await getAuthAuditLogs()); } catch (revokeError) { setError( @@ -767,6 +773,10 @@ export function AuthGate({ children }: AuthGateProps) { ? revokeError.message : '移除登录设备失败,请稍后再试。', ); + } finally { + setRevokingSessionIds((current) => + current.filter((id) => id !== session.sessionId), + ); } }} onLogoutAll={logoutAllSessions} @@ -795,11 +805,8 @@ export function AuthGate({ children }: AuthGateProps) { setUser(nextUser); }} onChangePassword={async (currentPassword, newPassword) => { - const nextUser = await changePassword( - currentPassword, - newPassword, - ); - setUser(nextUser); + await changePassword(currentPassword, newPassword); + clearLocalAuthenticatedState(); }} /> ) : null} diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index 11252950..2b181345 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -21,6 +21,7 @@ import { authEntry, bindWechatPhone, changePhoneNumber, + changePassword, consumeAuthCallbackResult, getAuthAuditLogs, getAuthLoginOptions, @@ -33,6 +34,8 @@ import { loginWithPhoneCode, logoutAllAuthSessions, redeemRegistrationInviteCode, + revokeAuthSession, + revokeAuthSessions, sendPhoneLoginCode, startWechatLogin, 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 () => { apiClientMocks.requestJson.mockResolvedValue({ ok: true, @@ -475,8 +516,15 @@ describe('authService', () => { sessions: [ { sessionId: 'usess_1', + sessionIds: ['usess_1', 'usess_2'], + sessionCount: 2, clientType: 'browser', + clientRuntime: 'chrome', + clientPlatform: 'windows', clientLabel: '网页端浏览器', + deviceDisplayName: 'Windows / Chrome', + miniProgramAppId: null, + miniProgramEnv: null, userAgent: 'Mozilla/5.0', ipMasked: '127.0.*.*', isCurrent: true, @@ -490,6 +538,46 @@ describe('authService', () => { const sessions = await getAuthSessions(); 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 () => { diff --git a/src/services/authService.ts b/src/services/authService.ts index 407287d2..0ea3c820 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -289,6 +289,7 @@ export async function changePassword( '修改密码失败', ); + clearAuthSession(); 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() { const response = await requestJson( '/api/auth/audit-logs', -- 2.43.0 From a92dc2b7b048b10a0b826e72ab9bab440ac463dd Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 16:07:54 +0800 Subject: [PATCH 2/2] fix(jenkins): add git fallback and nginx aliases --- .hermes/shared-memory/pitfalls.md | 8 +++ .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 13 ++--- jenkins/Jenkinsfile.production-api-deploy | 30 ++++++++--- .../Jenkinsfile.production-database-export | 30 ++++++++--- .../Jenkinsfile.production-database-import | 30 ++++++++--- .../Jenkinsfile.production-server-provision | 44 +++++++++++---- ...Jenkinsfile.production-stdb-module-publish | 30 ++++++++--- jenkins/Jenkinsfile.production-web-deploy | 30 ++++++++--- scripts/jenkins-checkout-source.sh | 53 +++++++++++++++++-- scripts/jenkins-server-provision.sh | 33 +++++++++++- 10 files changed, 239 insertions(+), 62 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index cbd9c0b1..0c11427d 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -438,6 +438,14 @@ - 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。 - 关联:`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 下不能裸读 - 现象:数据库导入或导出流水线报 `INCLUDE_TABLES: unbound variable`,或其它可选参数在 Bash 中未定义即退出。 diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index a1375337..ca5e6ed3 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -132,8 +132,8 @@ SpacetimeDB 公网路由默认保持收敛,只按实际前端 SDK 需要暴露 Nginx 配置文件分为两类: -- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live//fullchain.pem` 与 `privkey.pem` 已存在。 -- `deploy/nginx/genarrative-dev-http.conf`:开发服无域名时的 HTTP-only 配置,只允许 `DEPLOY_TARGET=development` 使用。没有域名时,`SERVER_NAME` 填开发机 IP 或临时主机名。它仍复用同一套静态目录、后台 API 反代、临时主站 `/api/*` 反代和 SpacetimeDB SDK 最小公网路由,不恢复旧 `/generated-*` 或公网 `/healthz`。 +- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live//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 或临时主机名;如有多个入口,额外域名或 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 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 服务抢端口。 -因此生产 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 凭证 @@ -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。 -首次真实初始化默认保持 `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//fullchain.pem` 与 `/etc/letsencrypt/live//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//fullchain.pem` 与 `/etc/letsencrypt/live//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`。 diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index ff48f52b..91449547 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -9,6 +9,7 @@ pipeline { environment { 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 { @@ -66,13 +67,25 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + 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 { if (params.COMMIT_HASH?.trim()) { 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 SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ 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" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index e19af792..c261e669 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -9,6 +9,7 @@ pipeline { environment { 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 { @@ -82,20 +83,33 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + 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 ''' bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh 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" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index 8013cddb..f30f61e8 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -9,6 +9,7 @@ pipeline { environment { 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 { @@ -140,20 +141,33 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + 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 ''' bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh 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" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index b809254f..157f4f0a 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -9,6 +9,7 @@ pipeline { environment { 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 { @@ -19,7 +20,8 @@ pipeline { booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') 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_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir') string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录') @@ -47,6 +49,17 @@ pipeline { if (!params.SERVER_NAME?.trim()) { 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()) { error('SPACETIME_BIN_SOURCE 不能为空。') } @@ -69,20 +82,33 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + 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 ''' bash <<'BASH' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh 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" \ scripts/jenkins-checkout-source.sh BASH diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 5aee3862..2f178f1f 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -9,6 +9,7 @@ pipeline { environment { 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 { @@ -78,20 +79,33 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + 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 ''' bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh 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" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index d2350236..e6b5a563 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -9,6 +9,7 @@ pipeline { environment { 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' } @@ -54,20 +55,33 @@ pipeline { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${params.SOURCE_BRANCH}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [[$class: 'CleanBeforeCheckout']], - userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + 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 ''' bash -lc ' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh 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" \ scripts/jenkins-checkout-source.sh ' diff --git a/scripts/jenkins-checkout-source.sh b/scripts/jenkins-checkout-source.sh index 509eb6d8..38e8c2d6 100644 --- a/scripts/jenkins-checkout-source.sh +++ b/scripts/jenkins-checkout-source.sh @@ -5,11 +5,14 @@ set -euo pipefail SOURCE_BRANCH="${SOURCE_BRANCH:-master}" COMMIT_HASH="${COMMIT_HASH:-}" GIT_REMOTE_URL="${GIT_REMOTE_URL:-}" +GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" SOURCE_COMMIT_FILE="${SOURCE_COMMIT_FILE:-.jenkins-source-commit}" # Windows PowerShell 5.1 的 UTF-8 输出可能带 BOM;下游参数校验前先剥离不可见字节。 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')" +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 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 fi -if [[ -n "${GIT_REMOTE_URL}" ]]; then - git remote set-url origin "${GIT_REMOTE_URL}" -fi +GIT_REMOTE_CANDIDATES=() +add_git_remote_candidate() { + 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 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 git fetch --unshallow --tags || true @@ -55,4 +98,4 @@ git reset --hard HEAD git clean -fd 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}" diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 8551a738..203518d4 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -9,6 +9,28 @@ require_path() { 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() { echo "+ $*" if [[ "${DRY_RUN}" != "true" ]]; then @@ -336,10 +358,15 @@ EOF render_nginx_template() { local template="$1" - local rendered_brotli + local rendered_brotli server_names rendered_brotli="$(render_nginx_brotli_directives)" + server_names="${SERVER_NAME}" + if [[ -n "${SERVER_ALIASES:-}" ]]; then + server_names="${server_names} $(normalize_server_aliases)" + fi 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__/d" \ "${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-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)" run_cmd id -- 2.43.0