diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 2f4b7705..7f0e8001 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -352,16 +352,29 @@ public class OAuthClaimsExtractor { 同一个员工通过不同 OAuth Provider 登录时,可能产生多个 `user_account`。 -一期策略:默认关闭自动合并,仅支持管理员手动合并。 +当前策略:不自动合并,旧的手动合并流程也已临时隔离。旧流程把次账号 verification +token 直接返回给主账号会话,不能分别证明两个账号的控制权,因此不能继续作为管理员或 +用户合并入口。 - 一期 GitHub-only:不需要自动合并,每个 Provider 登录独立创建用户 -- 多 Provider 上线时,再引入显式绑定/合并流程(用户主动发起 + 邮箱验证确认) -- 管理员可在后台手动合并两个 user_account(合并 identity_binding、迁移 skill ownership、合并角色取并集) +- 多 Provider 上线时,再引入显式 Identity Link 和安全 Account Merge +- email、username、display name 或主账号会话拿到的 token 均不能证明次账号所有权 +- 安全 Account Merge 必须要求主、次账号分别完成 fresh reauthentication +- 在安全流程上线前,`/api/v1/account/merge/initiate`、`verify`、`confirm` 对已认证请求 + 统一返回 `503 Service Unavailable` 合并操作规则: - 合并操作写入审计日志 - 合并后原 user_account 标记为 `MERGED`,保留记录不物理删除 -- 预留扩展位:未来可配置 `astron.identity.auto-merge-on-verified-email=true` 开启基于已验证邮箱的自动合并 +- 不提供按 email 自动合并;即使 Provider 声明 email 已验证,也不能替代对两个账号控制权 + 的分别证明。未来绑定/合并必须使用显式、可审计的重新认证流程。 +- 旧 `account_merge_request` 记录保留用于审计和未来迁移,但不得通过 SQL 手工改为 + `VERIFIED`/`COMPLETED`,也不得手工迁移身份、角色、membership、凭据或 Token +- 回滚到仍包含旧合并实现的镜像会重新暴露该安全问题;如必须回滚,应先在网关阻断 + `/api/v1/account/merge/*` + +未来安全流程的完整验收条件见 +[`22-secure-account-merge-acceptance-design.md`](./22-secure-account-merge-acceptance-design.md)。 ## 5. CLI 认证(OAuth Device Flow + 平台凭证) diff --git a/docs/10-delivery-roadmap.md b/docs/10-delivery-roadmap.md index f1d5b8c6..d1beb85b 100644 --- a/docs/10-delivery-roadmap.md +++ b/docs/10-delivery-roadmap.md @@ -99,7 +99,7 @@ ### 后端 - 本地认证体系(用户名密码注册/登录 + BCrypt + 密码策略 + 账号锁定) -- 多账号合并流程(发起 → 验证 → 确认 → 数据迁移) +- 多账号合并流程(旧实现已因控制权证明不足临时隔离,等待安全重构) - 技能治理(隐藏/恢复 + 已发布版本撤回 YANKED) - 审计日志查询 API(多条件筛选 + 分页) - Prometheus 指标暴露(Actuator + Micrometer 自定义业务指标) @@ -109,7 +109,7 @@ ### 前端 - 注册页、登录页扩展(用户名密码 + OAuth 双模式) -- 密码修改页、账号合并页 +- 密码修改页、账号合并安全隔离提示页 - 审计日志查询页 - 技能隐藏/恢复/已发布版本撤回操作(管理员可见) - 前端代码分割(TanStack Router lazy routes) @@ -125,7 +125,7 @@ ### 验收 -本地认证可用,多账号合并可用,技能隐藏/恢复/已发布版本撤回可用,审计日志可查询,Prometheus 指标可拉取,`docker compose up` 一键启动,K8s 清单可部署,开源基础设施齐全 +本地认证可用;多账号合并在主、次账号独立控制权证明完成前保持关闭;技能隐藏/恢复/已发布版本撤回可用,审计日志可查询,Prometheus 指标可拉取,`docker compose up` 一键启动,K8s 清单可部署,开源基础设施齐全 ## Phase 5:治理闭环 + 社交 diff --git a/docs/22-secure-account-merge-acceptance-design.md b/docs/22-secure-account-merge-acceptance-design.md new file mode 100644 index 00000000..8d881552 --- /dev/null +++ b/docs/22-secure-account-merge-acceptance-design.md @@ -0,0 +1,461 @@ +# SkillHub 安全账号合并验收设计 + +> 状态:Proposed +> +> 日期:2026-07-30 +> +> 上位设计:[#628 统一身份联邦架构](https://github.com/iflytek/skillhub/issues/628) +> +> P0 隔离:[#634](https://github.com/iflytek/skillhub/issues/634) +> +> 目标实现:统一身份计划 PR 6,必须在统一身份核心与 Provider Adapter 契约稳定后实施 + +## 1. 目的 + +本文定义未来安全 Account Merge 的实现边界和可执行验收条件。它不是 P0 隔离 PR 的实现 +清单,也不授权重新开启旧流程。 + +账号合并的含义是:两个已存在、都有独立登录方式和业务数据的 Platform Account,在分别 +证明控制权后,把允许迁移的数据收敛到主账号,并把次账号永久标记为 `MERGED`。 + +它不同于: + +- **首次登录建号**:外部身份还没有 Platform Account。 +- **Identity Link**:给同一个 Platform Account 增加一种登录方式。 +- **Profile Sync**:同步 display name、email、avatar 等资料字段。 +- **管理员改状态**:启用或禁用账号,不证明另一个账号的控制权。 +- **按 email 去重**:email 不是账号控制权证明,不能触发 Link 或 Merge。 + +## 2. P0 隔离契约 + +安全 Account Merge 上线前必须维持以下行为: + +1. 旧 `/api/v1/account/merge/initiate`、`verify`、`confirm` 路径保留,避免部署客户端因 + 404 误判,但对有效的已认证请求统一返回稳定的 `503 Service Unavailable`。 +2. 旧路径不解析次账号标识、不创建或更新 merge request、不返回 verification token, + 不迁移任何账号数据。 +3. 账号设置页面只显示不可用说明,不展示 username、`provider:subject`、request id、 + verification token 或确认控件。 +4. 现有 `account_merge_request` 表和记录保留;P0 不删除、不完成、不转换旧请求。 +5. 运维人员不得通过 SQL 把旧请求改为 `VERIFIED`/`COMPLETED`,也不得手工修改 + `identity_binding`、`api_token`、平台角色、namespace membership 或本地凭据来模拟合并。 +6. 回滚到旧镜像会恢复不安全实现。若发生必须回滚的故障,应先在 Ingress、网关或反向代理 + 阻断 `/api/v1/account/merge/*`。 + +## 3. 威胁模型 + +### 3.1 需要防御的攻击者 + +- 只控制主账号 Web Session,但知道次账号 username。 +- 只控制主账号 Web Session,但从页面、日志或其他系统知道 + `provider_code:subject`。 +- 窃取了一个旧 merge request id、state、浏览器历史或回调 URL。 +- 可以重放已完成或已过期的 proof。 +- 同时发起多个请求,尝试并发确认同一个合并。 +- 控制一个 OAuth/OIDC Provider 账号,但不控制碰撞 email 对应的平台账号。 +- 可以诱导已登录用户打开跨站页面,但不能读取同源响应。 +- 拥有普通管理员权限,试图把合并当作后台数据修复操作。 + +### 3.2 安全不变量 + +实现和测试必须证明: + +1. 主账号现有 Session 只表示“当前操作人已登录”,不能代替 fresh reauthentication。 +2. 主账号证明和次账号证明来自两个独立认证动作。 +3. 次账号只能由成功认证结果解析,主账号请求不能通过 email、username、 + `provider:subject` 或 userId 指定合并目标。 +4. proof 只保存在服务端,浏览器、API 响应、URL、日志和审计 detail 不出现可直接消费的 + raw proof。 +5. proof 同时绑定 merge request、对应账号、主账号 Session nonce、认证方法、签发时间和 + 短 TTL。 +6. request、state、proof 和 confirmation 都只能消费一次。 +7. 两个账号必须不同、均为 `ACTIVE`、均非 system account、均未 `MERGED`。 +8. 任一冲突、状态变化、过期、重放、Session 不匹配或并发竞争都必须 fail closed。 +9. 合并不会通过角色并集静默提升平台权限。 +10. 次账号 API Token 默认撤销,不迁移成主账号 Token。 +11. 数据库提交后,次账号旧 Web Session 即使尚未从 Redis 删除,也不能继续访问业务接口。 +12. 事务失败时不能留下部分 Binding、角色、membership、凭据、Token 或业务归属迁移。 + +## 4. 核心对象 + +### 4.1 Merge Intent + +服务端创建的一次性合并意图。建议使用不可预测 UUID,至少包含: + +```text +id +primary_user_id +secondary_user_id # 次账号认证成功前为 NULL +status +primary_session_nonce_hash +primary_proof_at +secondary_proof_at +expires_at +preview_version +preview_digest +confirmed_at +completed_at +row_version +created_at +updated_at +``` + +约束: + +- 不保存 raw Session ID、密码、OAuth token、authorization code、CAS Ticket、SAML + assertion 或 raw proof。 +- `primary_session_nonce_hash` 对 Session 中的高熵随机 nonce 做 SHA-256;不直接散列低熵 + 用户输入。 +- `secondary_user_id` 只能由认证成功后的统一身份核心写入。 +- `row_version` 或等价 CAS 条件用于防止并发消费。 +- `preview_digest` 只固定迁移计划,不包含可用于认证的秘密。 + +### 4.2 Fresh Reauthentication + +fresh reauthentication 不是“Session 仍有效”。它必须重新执行当前账号的一种可用登录方式: + +- 本地账号:重新校验密码、锁定和限流策略。 +- OAuth/OIDC:强制新的上游认证交互;不能直接复用当前 Platform Session。 +- LDAP、CAS、DingTalk 等:由对应 Adapter 重新验证协议结果,再进入统一身份核心。 + +主账号 proof 与次账号 proof 默认都在 10 分钟内有效;最终实现可以选择更短 TTL,但不得 +超过普通 Web Session 生命周期,也不能靠续期 Session 自动延长。 + +### 4.3 Session Binding + +创建 intent 时,在当前主账号 Session 生成一个合并专用高熵 nonce: + +```text +HttpSession: raw merge_session_nonce +Database: SHA-256(raw merge_session_nonce) +``` + +所有查询、次账号认证启动、预览、确认和取消都必须同时校验: + +- 当前 `principal.userId == primary_user_id` +- 当前 Session nonce hash 匹配 +- intent 未过期且状态允许该操作 + +换浏览器、换 Session、Session rotation 后未安全迁移 nonce,均不能继续旧 intent。 + +## 5. 必须采用的流程 + +```text +主账号已登录 + → 主账号 fresh reauthentication + → 创建 Merge Intent,并绑定主账号 Session nonce + → 启动独立的次账号认证 + → 统一身份核心由认证事实解析 secondary_user_id + → 服务端把次账号 proof 写入 Intent,不向浏览器返回 raw proof + → 生成冲突预览和 preview digest + → 主账号确认同一 preview version + → 单事务重新校验并迁移 PostgreSQL 数据 + → secondary account = MERGED,撤销 secondary API Token + → 事务提交 + → 每请求账号状态守卫立即拒绝次账号 + → 可靠任务删除次账号 Redis Session + → 审计完成 +``` + +### 5.1 发起 + +- 只接受主账号 Session 和完成的主账号 fresh reauthentication challenge。 +- 请求体不接受 `secondaryUserId`、email、username 或 `provider:subject`。 +- 返回 intent id、可用的次账号认证方式、到期时间;不返回 proof 或推测出的次账号信息。 +- 同一主账号可以只有一个有效 intent,或使用明确的数量限制;重复发起不能绕过限流。 + +### 5.2 次账号认证 + +- 使用独立认证事务,不把次账号 Principal 写入主账号 HttpSession。 +- Browser Provider 的 state 必须绑定 intent 和主账号 Session nonce。 +- 本地密码输入只进入专用 reauthentication 端点,并沿用密码锁定、限流和统一错误。 +- OAuth/OIDC 等回调完成后,只在服务端记录“该 intent 已证明控制 secondary_user_id”。 +- 次账号认证结果与主账号相同、账号不合格或已绑定到另一 intent 时返回通用错误,避免账号 + 枚举。 + +### 5.3 预览 + +预览必须从数据库实时读取,至少列出: + +- 将迁移的 Identity Binding 数量和 Provider 名称。 +- 本地凭据保留/迁移/丢弃策略。 +- 平台角色变化和所有阻塞的高权限角色。 +- Namespace membership 变化及角色。 +- 将撤销的次账号 API Token 名称、prefix 和数量,不返回 hash 或 raw token。 +- Skill ownership 和其他业务资源归属变化。 +- 阻塞冲突以及用户或管理员应先执行的解决动作。 + +预览生成 `preview_version` 和服务端 `preview_digest`。确认时必须重新计算;任何相关数据或 +账号状态变化都使旧预览失效并返回 `409 Conflict`。 + +### 5.4 确认 + +- 只接受创建 intent 的主账号 Session。 +- 主、次账号 proof 均未过期。 +- intent 状态为可确认,preview version 匹配,且没有阻塞冲突。 +- 使用行锁或等价并发控制按稳定顺序锁定两个账号和 intent。 +- 同一 intent 并发确认时只能有一个提交成功;其他请求返回已消费的稳定错误。 + +## 6. 状态机 + +建议状态: + +```text +PENDING_SECONDARY_PROOF + → READY_FOR_PREVIEW + → READY_TO_CONFIRM + → COMPLETED + +任一未完成状态 + → CANCELLED + → EXPIRED + → FAILED_CONFLICT +``` + +规则: + +- 不允许从 `COMPLETED`、`CANCELLED`、`EXPIRED` 返回可执行状态。 +- `FAILED_CONFLICT` 不能直接确认;冲突解决后创建新 preview,必要时创建新 intent。 +- 过期由请求时检查和后台清理共同执行,不能只依赖定时任务。 +- 所有状态迁移使用条件更新或乐观锁,不能“先查后改”而没有数据库竞争保护。 + +## 7. 数据迁移与冲突规则 + +### 7.1 Identity Binding + +- 只迁移 `ACTIVE` Binding。 +- 主账号已经存在同一 Provider Instance 的 ACTIVE Binding 时阻塞,不自动覆盖或选择。 +- typed Subject/Alias 的全局唯一约束必须继续成立。 +- 已撤销 Binding 保留历史归属,不自动复活。 + +### 7.2 本地凭据 + +- 主账号无本地凭据、次账号有本地凭据:可以迁移到主账号。 +- 两个账号都有本地凭据:保留主账号凭据并使次账号凭据失效;该结果必须在预览中明确展示。 +- username 唯一约束、锁定状态和密码策略不能因迁移被绕过。 +- 不复制 password hash,不让同一凭据同时属于两个账号。 + +### 7.3 平台角色 + +- 默认角色无需复制。 +- 次账号拥有任何非默认平台角色时,第一版安全实现应阻塞合并,由平台管理员先显式调整。 +- 禁止使用“角色取并集”作为默认行为,避免普通主账号通过合并获得管理权限。 +- 管理员调整和最终合并分别写审计。 + +### 7.4 Namespace Membership + +- 只有次账号存在 membership:迁移同一角色。 +- 两个账号都有 membership:保留权限更高者,并在预览中展示。 +- 如果迁移会让主账号新获得 `OWNER`,第一版应阻塞;先通过 Namespace 治理流程显式转移 + ownership,再重新发起合并。 +- 必须继续满足 `(namespace_id, user_id)` 唯一约束和 Namespace 最后所有者规则。 + +### 7.5 API Token + +- 次账号所有未撤销 API Token 在数据库事务内统一设置 `revoked_at`。 +- 不把次账号 Token 的 `user_id` 或 `subject_id` 改成主账号。 +- 主账号 Token 不变。 +- 预览只显示非秘密元数据;完成响应不返回任何新 Token。 + +### 7.6 业务数据 + +PR 6 开始前必须建立所有 userId 引用的迁移清单,并分类: + +1. **当前归属**:如 Skill owner,需要迁移。 +2. **当前授权**:如 membership、role、Token,按本设计处理。 +3. **历史事实**:如 audit actor、过去的 reviewer/creator,应保留原 userId,不重写历史。 +4. **通知和临时数据**:明确迁移、失效或删除策略。 + +任何未分类的 userId 外键、字符串引用或 JSON 引用都阻塞发布。不能只迁移旧 +`AccountMergeService` 已知的几张表就宣称完成。 + +## 8. 事务、Session 与跨存储一致性 + +### 8.1 PostgreSQL 事务 + +以下操作必须在同一事务: + +- 锁定和重新校验 intent、主账号、次账号。 +- 重算 preview digest。 +- 迁移允许迁移的 Binding、credential、membership 和业务归属。 +- 撤销次账号 API Token。 +- 把次账号设为 `MERGED` 并写 `merged_to_user_id`。 +- 把 intent 标记为 `COMPLETED`。 +- 写数据库审计或可靠 outbox 事件。 + +任一 repository 失败必须整体回滚。 + +### 8.2 Redis Session + +PostgreSQL 与 Redis 不能依赖普通本地事务实现原子提交,因此需要两层保证: + +1. 所有基于 Web Session 的请求在授权前检查账号仍可登录;数据库已是 `MERGED` 时立即 + 拒绝,即使 Redis 中还存在旧 Principal 快照。 +2. PostgreSQL 提交后,通过可重试的 session revocation 任务按次账号删除所有 indexed + Spring Session。任务失败必须有指标、告警和重试,不能吞掉异常。 + +只有“删 Redis Session”而没有每请求状态守卫,会产生提交到删除之间的继续访问窗口; +只有状态守卫而不删除 Session,会留下长期无效 Session。两者都必须实现。 + +### 8.3 API Token + +Token 在同一个 PostgreSQL 事务撤销。Token Authentication 还必须检查账号状态,因此即使 +个别旧 Token 未被清理,`MERGED` 账号也不能认证成功。 + +## 9. API 与错误语义 + +未来 API 应使用新资源式路径,不复活旧 token 驱动接口。建议: + +```text +POST /api/v1/account/merge/intents +POST /api/v1/account/merge/intents/{id}/secondary-auth/start +GET /api/v1/account/merge/intents/{id} +POST /api/v1/account/merge/intents/{id}/preview +POST /api/v1/account/merge/intents/{id}/confirm +DELETE /api/v1/account/merge/intents/{id} +``` + +协议回调由各 Adapter 的既有 callback transport 处理,最终只调用统一的 server-side proof +完成接口,不把 proof 暴露为公共请求参数。 + +至少定义以下稳定 reason code: + +| reason code | HTTP | 含义 | +|---|---:|---| +| `ACCOUNT_MERGE_UNAVAILABLE` | 503 | 功能尚未安全启用 | +| `MERGE_INTENT_NOT_FOUND` | 404 | 不存在或当前 Session 不可见 | +| `MERGE_REAUTH_REQUIRED` | 401 | 需要重新认证 | +| `MERGE_SESSION_MISMATCH` | 403 | Intent 不属于当前 Session | +| `MERGE_PROOF_EXPIRED` | 410 | 任一 proof 或 intent 已过期 | +| `MERGE_CONFLICT` | 409 | 数据或权限存在阻塞冲突 | +| `MERGE_PREVIEW_STALE` | 409 | 确认的预览已失效 | +| `MERGE_ALREADY_CONSUMED` | 409 | 已完成、取消或并发消费 | +| `MERGE_ACCOUNT_NOT_ELIGIBLE` | 409 | 账号状态不允许合并 | + +面向未证明身份的响应不得泄露目标 userId、完整 email、角色、membership、Provider subject +或账号是否存在。 + +## 10. 审计、日志与指标 + +审计事件至少包括: + +```text +ACCOUNT_MERGE_INTENT_CREATED +ACCOUNT_MERGE_PRIMARY_REAUTHENTICATED +ACCOUNT_MERGE_SECONDARY_REAUTHENTICATED +ACCOUNT_MERGE_PREVIEWED +ACCOUNT_MERGE_CONFIRMED +ACCOUNT_MERGE_COMPLETED +ACCOUNT_MERGE_CANCELLED +ACCOUNT_MERGE_EXPIRED +ACCOUNT_MERGE_REJECTED +ACCOUNT_MERGE_SESSION_REVOCATION_RETRIED +``` + +审计 detail 可以记录 request id、主/次账号内部 ID、Provider code、迁移数量、冲突 reason +code 和结果,但不能记录密码、raw proof、Session ID/nonce、OAuth token、authorization +code、Ticket、SAML assertion、API Token hash 或完整上游响应。 + +指标至少覆盖 intent 创建、proof 成功/失败、冲突、过期、完成、事务回滚和 Session 撤销 +重试;Provider code 可以作为受控低基数标签,userId、request id 和 intent id 不能作为 +指标标签。 + +## 11. 升级、启用与回滚 + +### 11.1 旧请求 + +- 旧 `PENDING`/`VERIFIED` 请求不携带可信的次账号 proof,不能转换为可确认的新 intent。 +- 新版本可以把它们标记为 `LEGACY_BLOCKED`/过期,或保留只读;无论采用哪种方式,都必须 + 保留审计证据,不能自动完成。 +- 用户必须从头执行主、次账号 fresh reauthentication。 + +### 11.2 滚动升级 + +1. 数据库只做 additive migration,新旧 Pod 共存时旧路径仍由网关阻断。 +2. 部署所有包含新实现的 Pod,但新 Account Merge 功能保持关闭。 +3. 验证 schema、Session index、状态守卫、Provider reauthentication 和回滚脚本。 +4. 确认没有旧 Pod 后再打开新资源式 API 和 UI。 +5. 旧 initiate/verify/confirm 路径继续返回 503,不重定向到新确认接口。 + +### 11.3 回滚 + +- 关闭新 UI 和新 intent 创建。 +- 已 `COMPLETED` 的合并不能靠镜像回滚自动拆分;拆分账号需要独立、人工审核的数据恢复 + 流程。 +- 未完成的新 intent 可统一失效。 +- 数据库 additive 字段和表保留,不在应用回滚时删除。 +- 不得回滚到会重新开放旧 token 流程的版本;如果镜像回滚不可避免,网关阻断规则必须先 + 生效。 + +## 12. 自动化验收矩阵 + +### 12.1 控制权证明 + +- 只有主账号 Session,无法产生次账号 proof。 +- email、username、display name、userId、`provider:subject` 均不能替代次账号认证。 +- 次账号密码错误、锁定、禁用、待审批、已合并或 system account 时失败。 +- OAuth/OIDC state 不能用于另一个 intent 或另一个主账号 Session。 +- proof、Session nonce、callback 和 confirmation 不出现在日志或审计 detail。 + +### 12.2 过期与重放 + +- 主 proof 过期、次 proof 过期、intent 过期分别 fail closed。 +- 已完成、取消、过期的 intent 无法恢复。 +- 同一 callback 重放、同一 confirmation 重放都失败。 +- 两个线程同时确认,只有一个成功,另一个得到 `MERGE_ALREADY_CONSUMED`。 + +### 12.3 冲突与权限 + +- 两账号相同、状态变化、system account、`MERGED` 均失败。 +- 同 Provider Binding 冲突不覆盖。 +- 非默认平台角色阻塞,不执行角色并集。 +- Namespace OWNER 新增冲突阻塞。 +- preview 后新增 Token、Binding、membership 或业务归属会让 preview stale。 + +### 12.4 原子性 + +在 Binding、credential、membership、业务归属、Token 撤销、账号状态、intent 状态和审计 +每一步注入失败,验证所有 PostgreSQL 数据回到事务前状态。 + +### 12.5 Session 与 Token + +- 合并提交后,次账号现有 Web Session 下一次请求立即被拒绝。 +- Redis 删除失败时,请求仍被状态守卫拒绝,并产生重试任务和指标。 +- 重试成功后次账号所有 indexed Session 消失。 +- 次账号所有 API Token 被撤销,旧 Token 返回 401;主账号 Token 不受影响。 + +### 12.6 升级与回滚 + +- 从包含旧 `account_merge_request` 数据的版本升级,旧请求不能确认。 +- 新旧 Pod 混跑期间旧路径始终被阻断。 +- 关闭功能后不能新建 intent,已有未完成 intent 按策略失效。 +- 应用版本回滚不删除新表,也不让旧 token 流程恢复可用。 + +## 13. 测试环境人工验收 + +在 `big-main` 测试镜像完成自动化检查后,人工至少验证: + +1. 旧三个接口:未登录为 401;缺少有效 CSRF 时由现有安全链以 4xx 拒绝(当前实现为 + 401);已登录、CSRF 有效且请求合法时统一返回 503。 +2. 账号设置页只显示中英文不可用说明,没有 identifier、request id、token 和确认按钮。 +3. 普通本地登录、OAuth 登录、`/api/v1/auth/me`、Namespace 列表和 Skill 浏览不受影响。 +4. 数据库已有 `account_merge_request` 数量和内容未被 P0 隔离部署修改。 +5. 日志只包含稳定错误和 request id,不包含请求中的 secondary identifier 或 token。 +6. 部署和回滚文档明确要求旧路径网关阻断;不得通过数据库手工演示“成功合并”。 + +PR 6 上线时,再执行本文件第 12 节的完整双账号、Redis Session、API Token、冲突、并发和 +事务回滚验收。 + +## 14. 合并门禁 + +PR 6 只有同时满足以下条件才可以从 `big-main` 进入 `main`: + +- 统一身份核心、Binding V2、Provider Registry/Adapter 契约已经稳定。 +- 独立 Identity Link 已经证明 fresh reauthentication、一次性 request 和重放保护可用。 +- 本文件所有自动化验收项有对应测试和可追溯结果。 +- PostgreSQL + Redis 真实集成测试通过。 +- 测试环境完成双账号人工验收,记录镜像 tag、commit、请求结果和数据前后快照。 +- 安全 Review 没有 Blocker/Critical 发现。 +- 运维、升级和回滚文档同步完成。 + +不满足任一门禁时,继续保持 Account Merge 不可用;不得以管理员手工操作作为替代方案。 diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index 7cc4500f..5b6c8dd7 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -323,6 +323,21 @@ xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir" Since **SkillHub Server v0.2.12**, public skills support anonymous search and install. Note that an invalid bearer token now fails the command instead of falling back to anonymous access — update or remove the stale credential in that case. +## Q: Why does the account merge page say that merging is temporarily unavailable? + +A: The legacy account merge flow could not independently prove control of the primary and +secondary accounts, so it has been isolated as a security measure. Until the replacement +double-reauthentication flow is available: + +- Continue using the two accounts separately. +- Do not ask an administrator to edit the database or manually move identity bindings, roles, + namespace memberships, local credentials, or API tokens. +- Existing `account_merge_request` rows are retained but cannot be completed. +- Authenticated API clients calling the legacy routes receive `503 Service Unavailable`. + +Normal login, existing identity binding lookup, namespace operations, and skill operations are not +affected. + ## Q: What should I do if I encounter issues? A: You can get help through the following channels: diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index b16ecd81..a9de4e5e 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -323,6 +323,19 @@ xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir" 自 **SkillHub Server v0.2.12** 起,公开技能支持匿名搜索与安装;如果配置了无效的 Bearer Token,命令会直接失败而不再回退匿名访问,遇到这种情况请更新凭据或先移除无效 Token。 +## Q: 为什么账号合并页面显示暂时不可用? + +A: 旧的账号合并流程不能分别证明主账号和次账号的控制权,因此已被安全隔离。在新的 +双重重新认证流程上线前: + +- 请继续分别使用两个账号。 +- 不要让管理员直接修改数据库、移动 identity binding、角色、namespace membership、 + 本地凭据或 API Token。 +- 已存在的 `account_merge_request` 记录会被保留,但不会继续执行。 +- 如果 API 客户端仍调用旧接口,已认证请求会收到 `503 Service Unavailable`。 + +这不会影响普通登录、身份绑定读取、Namespace 或 Skill 操作。 + ## Q: 遇到问题怎么办? A: 可以通过以下方式获取帮助: diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java index b7e1386e..c196af72 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java @@ -1,6 +1,6 @@ package com.iflytek.skillhub.controller; -import com.iflytek.skillhub.auth.merge.AccountMergeService; +import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; @@ -10,6 +10,7 @@ import com.iflytek.skillhub.dto.MergeVerifyRequest; import com.iflytek.skillhub.dto.MessageResponse; import com.iflytek.skillhub.exception.UnauthorizedException; import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -17,19 +18,19 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** - * Endpoints for initiating, verifying, and confirming account merge flows - * across multiple identities owned by the same user. + * Compatibility endpoints for the temporarily isolated legacy account merge flow. + * + *

The previous implementation returned the secondary-account verification token to the + * primary-account session and therefore did not prove independent control of both accounts. Keep + * the routes stable for deployed clients, but fail closed until the safe account merge flow is + * implemented. */ @RestController @RequestMapping("/api/v1/account/merge") public class AccountMergeController extends BaseApiController { - private final AccountMergeService accountMergeService; - - public AccountMergeController(ApiResponseFactory responseFactory, - AccountMergeService accountMergeService) { + public AccountMergeController(ApiResponseFactory responseFactory) { super(responseFactory); - this.accountMergeService = accountMergeService; } @PostMapping("/initiate") @@ -38,13 +39,7 @@ public class AccountMergeController extends BaseApiController { if (principal == null) { throw new UnauthorizedException("error.auth.required"); } - var result = accountMergeService.initiate(principal.userId(), request.secondaryIdentifier()); - return ok("response.success.created", new MergeInitiateResponse( - result.mergeRequestId(), - result.secondaryUserId(), - result.verificationToken(), - result.expiresAt().toString() - )); + throw mergeTemporarilyUnavailable(); } @PostMapping("/verify") @@ -53,12 +48,7 @@ public class AccountMergeController extends BaseApiController { if (principal == null) { throw new UnauthorizedException("error.auth.required"); } - accountMergeService.verify( - principal.userId(), - request.mergeRequestId(), - request.verificationToken() - ); - return ok("response.success.updated", new MessageResponse("Account merge verified")); + throw mergeTemporarilyUnavailable(); } @PostMapping("/confirm") @@ -67,9 +57,15 @@ public class AccountMergeController extends BaseApiController { if (principal == null) { throw new UnauthorizedException("error.auth.required"); } - accountMergeService.confirm(principal.userId(), request.mergeRequestId()); - return ok("response.success.updated", new MessageResponse("Account merge completed")); + throw mergeTemporarilyUnavailable(); } public record ConfirmMergeRequest(@jakarta.validation.constraints.NotNull Long mergeRequestId) {} + + private AuthFlowException mergeTemporarilyUnavailable() { + return new AuthFlowException( + HttpStatus.SERVICE_UNAVAILABLE, + "error.auth.merge.temporarilyUnavailable" + ); + } } diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 249644c7..d4ca53fe 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -46,6 +46,7 @@ error.auth.direct.providerUnsupported=Unsupported direct authentication provider error.auth.sessionBootstrap.disabled=Session bootstrap is disabled error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0} error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found +error.auth.merge.temporarilyUnavailable=Account merging is temporarily unavailable while the ownership verification flow is being secured error.badRequest=Invalid request error.methodNotAllowed=HTTP method is not supported error.unsupportedMediaType=Unsupported media type diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index b133c661..bc7f06af 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -46,6 +46,7 @@ error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0} error.auth.sessionBootstrap.disabled=会话引导能力未启用 error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0} error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话 +error.auth.merge.temporarilyUnavailable=账号合并功能正在进行安全升级,暂时不可用 error.badRequest=请求参数不合法 error.methodNotAllowed=不支持的请求方法 error.unsupportedMediaType=不支持的请求内容类型 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java index e0d5677b..85e8a897 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java @@ -1,18 +1,15 @@ package com.iflytek.skillhub.controller; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.iflytek.skillhub.auth.merge.AccountMergeService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import java.time.Instant; import java.util.List; +import java.util.Locale; import java.util.Set; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -21,7 +18,6 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -33,69 +29,95 @@ class AccountMergeControllerTest { @Autowired private MockMvc mockMvc; - @MockBean - private AccountMergeService accountMergeService; - @MockBean private NamespaceMemberRepository namespaceMemberRepository; @Test - void initiate_returnsVerificationToken() throws Exception { - PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); - var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); - given(accountMergeService.initiate("usr_primary", "secondary")) - .willReturn(new AccountMergeService.InitiationResult(1L, "usr_secondary", "merge-token", Instant.parse("2026-03-12T22:30:00Z"))); - + void initiate_failsClosedWithoutReturningSecondaryAccountProof() throws Exception { + mockMvc.perform(post("/api/v1/account/merge/initiate") + .with(authentication(primaryAuthentication())) + .with(csrf()) + .locale(Locale.ENGLISH) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"secondaryIdentifier":"secondary"} + """)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value(503)) + .andExpect(jsonPath("$.msg").value( + "Account merging is temporarily unavailable while the ownership verification flow is being secured" + )) + .andExpect(jsonPath("$.data.verificationToken").doesNotExist()) + .andExpect(jsonPath("$.data.secondaryUserId").doesNotExist()) + .andExpect(jsonPath("$.data.mergeRequestId").doesNotExist()); + } + + @Test + void verify_failsClosedWithTheSameStableError() throws Exception { + mockMvc.perform(post("/api/v1/account/merge/verify") + .with(authentication(primaryAuthentication())) + .with(csrf()) + .locale(Locale.ENGLISH) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mergeRequestId":1,"verificationToken":"merge-token"} + """)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value(503)) + .andExpect(jsonPath("$.msg").value( + "Account merging is temporarily unavailable while the ownership verification flow is being secured" + )); + } + + @Test + void confirm_failsClosedWithTheSameStableError() throws Exception { + mockMvc.perform(post("/api/v1/account/merge/confirm") + .with(authentication(primaryAuthentication())) + .with(csrf()) + .locale(Locale.ENGLISH) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mergeRequestId":1} + """)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value(503)) + .andExpect(jsonPath("$.msg").value( + "Account merging is temporarily unavailable while the ownership verification flow is being secured" + )); + } + + @Test + void initiate_withoutCsrf_isRejectedByTheExistingSecurityChain() throws Exception { + mockMvc.perform(post("/api/v1/account/merge/initiate") + .with(authentication(primaryAuthentication())) + .with(csrf().useInvalidToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"secondaryIdentifier":"secondary"} + """)) + .andExpect(status().isForbidden()); + } + + @Test + void initiate_withoutAuthentication_remainsUnauthorized() throws Exception { mockMvc.perform(post("/api/v1/account/merge/initiate") - .with(authentication(auth)) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(""" {"secondaryIdentifier":"secondary"} """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.mergeRequestId").value(1)) - .andExpect(jsonPath("$.data.secondaryUserId").value("usr_secondary")) - .andExpect(jsonPath("$.data.verificationToken").value("merge-token")) - .andExpect(jsonPath("$.data.expiresAt").value("2026-03-12T22:30:00Z")); + .andExpect(status().isUnauthorized()); } - @Test - void verify_returnsSuccessMessage() throws Exception { - PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); - var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); - - mockMvc.perform(post("/api/v1/account/merge/verify") - .with(authentication(auth)) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"mergeRequestId":1,"verificationToken":"merge-token"} - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.message").value("Account merge verified")); - - verify(accountMergeService).verify("usr_primary", 1L, "merge-token"); - } - - @Test - void confirm_returnsSuccessMessage() throws Exception { - PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); - var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); - - mockMvc.perform(post("/api/v1/account/merge/confirm") - .with(authentication(auth)) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"mergeRequestId":1} - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.message").value("Account merge completed")); - - verify(accountMergeService).confirm("usr_primary", 1L); + private UsernamePasswordAuthenticationToken primaryAuthentication() { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_primary", + "primary", + "p@example.com", + "", + "local", + Set.of() + ); + return new UsernamePasswordAuthenticationToken(principal, null, List.of()); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java deleted file mode 100644 index 20fc4080..00000000 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java +++ /dev/null @@ -1,288 +0,0 @@ -package com.iflytek.skillhub.auth.merge; - -import com.iflytek.skillhub.auth.entity.ApiToken; -import com.iflytek.skillhub.auth.entity.IdentityBinding; -import com.iflytek.skillhub.auth.entity.Role; -import com.iflytek.skillhub.auth.entity.UserRoleBinding; -import com.iflytek.skillhub.auth.exception.AuthFlowException; -import com.iflytek.skillhub.auth.local.LocalCredential; -import com.iflytek.skillhub.auth.local.LocalCredentialRepository; -import com.iflytek.skillhub.auth.repository.ApiTokenRepository; -import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; -import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceMember; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; -import com.iflytek.skillhub.domain.user.UserStatus; -import java.security.SecureRandom; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.Base64; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Optional; -import java.util.Set; -import org.springframework.http.HttpStatus; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * Coordinates account merge requests and consolidates credentials, bindings, - * roles, memberships, and tokens into a single primary user. - */ -@Service -public class AccountMergeService { - - private static final Comparator NAMESPACE_ROLE_ORDER = Comparator.comparingInt(role -> switch (role) { - case MEMBER -> 0; - case ADMIN -> 1; - case OWNER -> 2; - }); - - private final AccountMergeRequestRepository mergeRequestRepository; - private final UserAccountRepository userAccountRepository; - private final LocalCredentialRepository localCredentialRepository; - private final IdentityBindingRepository identityBindingRepository; - private final UserRoleBindingRepository userRoleBindingRepository; - private final ApiTokenRepository apiTokenRepository; - private final NamespaceMemberRepository namespaceMemberRepository; - private final PasswordEncoder passwordEncoder; - private final Clock clock; - private final SecureRandom secureRandom = new SecureRandom(); - - public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, - UserAccountRepository userAccountRepository, - LocalCredentialRepository localCredentialRepository, - IdentityBindingRepository identityBindingRepository, - UserRoleBindingRepository userRoleBindingRepository, - ApiTokenRepository apiTokenRepository, - NamespaceMemberRepository namespaceMemberRepository, - PasswordEncoder passwordEncoder, - Clock clock) { - this.mergeRequestRepository = mergeRequestRepository; - this.userAccountRepository = userAccountRepository; - this.localCredentialRepository = localCredentialRepository; - this.identityBindingRepository = identityBindingRepository; - this.userRoleBindingRepository = userRoleBindingRepository; - this.apiTokenRepository = apiTokenRepository; - this.namespaceMemberRepository = namespaceMemberRepository; - this.passwordEncoder = passwordEncoder; - this.clock = clock; - } - - public record InitiationResult(Long mergeRequestId, String secondaryUserId, String verificationToken, Instant expiresAt) {} - - @Transactional - public InitiationResult initiate(String primaryUserId, String secondaryIdentifier) { - UserAccount primaryUser = loadActiveUser(primaryUserId); - UserAccount secondaryUser = resolveSecondaryUser(secondaryIdentifier); - validateMergePair(primaryUser, secondaryUser); - - if (mergeRequestRepository.existsBySecondaryUserIdAndStatus( - secondaryUser.getId(), - AccountMergeRequest.STATUS_PENDING - )) { - throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.pendingExists"); - } - - Optional primaryCredential = localCredentialRepository.findByUserId(primaryUserId); - Optional secondaryCredential = localCredentialRepository.findByUserId(secondaryUser.getId()); - if (primaryCredential.isPresent() && secondaryCredential.isPresent()) { - throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict"); - } - - String rawToken = generateVerificationToken(); - AccountMergeRequest request = new AccountMergeRequest( - primaryUserId, - secondaryUser.getId(), - passwordEncoder.encode(rawToken), - currentTime().plus(Duration.ofMinutes(30)) - ); - request = mergeRequestRepository.save(request); - return new InitiationResult(request.getId(), secondaryUser.getId(), rawToken, request.getTokenExpiresAt()); - } - - @Transactional - public void verify(String primaryUserId, Long mergeRequestId, String verificationToken) { - AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) - .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); - if (!AccountMergeRequest.STATUS_PENDING.equals(request.getStatus())) { - throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotPending"); - } - if (request.getTokenExpiresAt() == null || request.getTokenExpiresAt().isBefore(currentTime())) { - throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.tokenExpired"); - } - if (!passwordEncoder.matches(verificationToken, request.getVerificationToken())) { - throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.merge.invalidToken"); - } - - loadActiveUser(primaryUserId); - UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) - .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); - validateMergePair(loadActiveUser(primaryUserId), secondaryUser); - - request.setStatus(AccountMergeRequest.STATUS_VERIFIED); - mergeRequestRepository.save(request); - } - - @Transactional - public void confirm(String primaryUserId, Long mergeRequestId) { - AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) - .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); - if (!AccountMergeRequest.STATUS_VERIFIED.equals(request.getStatus())) { - throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotVerified"); - } - - UserAccount primaryUser = loadActiveUser(primaryUserId); - UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) - .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); - validateMergePair(primaryUser, secondaryUser); - - migrateIdentityBindings(primaryUser.getId(), secondaryUser.getId()); - migrateApiTokens(primaryUser.getId(), secondaryUser.getId()); - migrateUserRoles(primaryUser.getId(), secondaryUser.getId()); - migrateNamespaceMemberships(primaryUser.getId(), secondaryUser.getId()); - migrateLocalCredential(primaryUser.getId(), secondaryUser.getId()); - - if ((primaryUser.getEmail() == null || primaryUser.getEmail().isBlank()) - && secondaryUser.getEmail() != null && !secondaryUser.getEmail().isBlank()) { - primaryUser.setEmail(secondaryUser.getEmail()); - } - userAccountRepository.save(primaryUser); - - secondaryUser.setStatus(UserStatus.MERGED); - secondaryUser.setMergedToUserId(primaryUser.getId()); - userAccountRepository.save(secondaryUser); - - request.setStatus(AccountMergeRequest.STATUS_COMPLETED); - request.setCompletedAt(currentTime()); - request.setVerificationToken(null); - mergeRequestRepository.save(request); - } - - private UserAccount resolveSecondaryUser(String identifier) { - String normalized = identifier == null ? "" : identifier.trim(); - if (normalized.isBlank()) { - throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.identifierRequired"); - } - if (normalized.contains(":")) { - String[] parts = normalized.split(":", 2); - if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { - throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.identifierInvalid"); - } - IdentityBinding binding = identityBindingRepository.findByProviderCodeAndSubject(parts[0], parts[1]) - .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); - return userAccountRepository.findById(binding.getUserId()) - .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); - } - - LocalCredential credential = localCredentialRepository.findByUsernameIgnoreCase(normalized.toLowerCase(Locale.ROOT)) - .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); - return userAccountRepository.findById(credential.getUserId()) - .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); - } - - private UserAccount loadActiveUser(String userId) { - UserAccount user = userAccountRepository.findById(userId) - .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.primaryNotFound")); - if (user.getStatus() != UserStatus.ACTIVE) { - throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.primaryNotActive"); - } - return user; - } - - private void validateMergePair(UserAccount primaryUser, UserAccount secondaryUser) { - if (primaryUser.getId().equals(secondaryUser.getId())) { - throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.sameAccount"); - } - if (secondaryUser.getStatus() != UserStatus.ACTIVE) { - throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.secondaryNotActive"); - } - } - - private void migrateIdentityBindings(String primaryUserId, String secondaryUserId) { - List bindings = identityBindingRepository.findByUserId(secondaryUserId); - for (IdentityBinding binding : bindings) { - binding.setUserId(primaryUserId); - } - identityBindingRepository.saveAll(bindings); - } - - private void migrateApiTokens(String primaryUserId, String secondaryUserId) { - List tokens = apiTokenRepository.findByUserId(secondaryUserId); - for (ApiToken token : tokens) { - token.setUserId(primaryUserId); - if ("USER".equals(token.getSubjectType())) { - token.setSubjectId(primaryUserId); - } - } - apiTokenRepository.saveAll(tokens); - } - - private void migrateUserRoles(String primaryUserId, String secondaryUserId) { - Set primaryRoleCodes = new HashSet<>(); - for (UserRoleBinding binding : userRoleBindingRepository.findByUserId(primaryUserId)) { - primaryRoleCodes.add(binding.getRole().getCode()); - } - - List secondaryBindings = userRoleBindingRepository.findByUserId(secondaryUserId); - for (UserRoleBinding binding : secondaryBindings) { - Role role = binding.getRole(); - if (!primaryRoleCodes.contains(role.getCode())) { - userRoleBindingRepository.save(new UserRoleBinding(primaryUserId, role)); - primaryRoleCodes.add(role.getCode()); - } - } - userRoleBindingRepository.deleteAll(secondaryBindings); - } - - private void migrateNamespaceMemberships(String primaryUserId, String secondaryUserId) { - List secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId); - for (NamespaceMember secondaryMembership : secondaryMemberships) { - Optional existingPrimaryMembership = namespaceMemberRepository - .findByNamespaceIdAndUserId(secondaryMembership.getNamespaceId(), primaryUserId); - if (existingPrimaryMembership.isPresent()) { - NamespaceMember primaryMembership = existingPrimaryMembership.get(); - if (NAMESPACE_ROLE_ORDER.compare(secondaryMembership.getRole(), primaryMembership.getRole()) > 0) { - primaryMembership.setRole(secondaryMembership.getRole()); - namespaceMemberRepository.save(primaryMembership); - } - namespaceMemberRepository.deleteByNamespaceIdAndUserId( - secondaryMembership.getNamespaceId(), - secondaryUserId - ); - } else { - secondaryMembership.setUserId(primaryUserId); - namespaceMemberRepository.save(secondaryMembership); - } - } - } - - private void migrateLocalCredential(String primaryUserId, String secondaryUserId) { - Optional primaryCredential = localCredentialRepository.findByUserId(primaryUserId); - Optional secondaryCredential = localCredentialRepository.findByUserId(secondaryUserId); - if (primaryCredential.isPresent() && secondaryCredential.isPresent()) { - throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict"); - } - secondaryCredential.ifPresent(credential -> { - credential.setUserId(primaryUserId); - localCredentialRepository.save(credential); - }); - } - - private String generateVerificationToken() { - byte[] tokenBytes = new byte[24]; - secureRandom.nextBytes(tokenBytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); - } - - private Instant currentTime() { - return Instant.now(clock); - } -} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java deleted file mode 100644 index d882386d..00000000 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.iflytek.skillhub.auth.merge; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -import com.iflytek.skillhub.auth.entity.ApiToken; -import com.iflytek.skillhub.auth.entity.IdentityBinding; -import com.iflytek.skillhub.auth.entity.Role; -import com.iflytek.skillhub.auth.entity.UserRoleBinding; -import com.iflytek.skillhub.auth.exception.AuthFlowException; -import com.iflytek.skillhub.auth.local.LocalCredential; -import com.iflytek.skillhub.auth.local.LocalCredentialRepository; -import com.iflytek.skillhub.auth.repository.ApiTokenRepository; -import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; -import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceMember; -import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; -import java.lang.reflect.Field; -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.password.PasswordEncoder; - -@ExtendWith(MockitoExtension.class) -class AccountMergeServiceTest { - - @Mock - private AccountMergeRequestRepository mergeRequestRepository; - @Mock - private UserAccountRepository userAccountRepository; - @Mock - private LocalCredentialRepository localCredentialRepository; - @Mock - private IdentityBindingRepository identityBindingRepository; - @Mock - private UserRoleBindingRepository userRoleBindingRepository; - @Mock - private ApiTokenRepository apiTokenRepository; - @Mock - private NamespaceMemberRepository namespaceMemberRepository; - @Mock - private PasswordEncoder passwordEncoder; - - private AccountMergeService service; - private Clock clock; - - @BeforeEach - void setUp() { - clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); - service = new AccountMergeService( - mergeRequestRepository, - userAccountRepository, - localCredentialRepository, - identityBindingRepository, - userRoleBindingRepository, - apiTokenRepository, - namespaceMemberRepository, - passwordEncoder, - clock - ); - } - - @Test - void initiate_withLocalUsername_createsPendingRequest() { - UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); - UserAccount secondary = new UserAccount("usr_secondary", "secondary", "secondary@example.com", null); - LocalCredential secondaryCredential = new LocalCredential("usr_secondary", "secondary", "hash"); - given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); - given(localCredentialRepository.findByUsernameIgnoreCase("secondary")).willReturn(Optional.of(secondaryCredential)); - given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); - given(mergeRequestRepository.existsBySecondaryUserIdAndStatus("usr_secondary", AccountMergeRequest.STATUS_PENDING)) - .willReturn(false); - given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); - given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.of(secondaryCredential)); - given(passwordEncoder.encode(any())).willReturn("encoded-token"); - given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); - - var result = service.initiate("usr_primary", "secondary"); - - assertThat(result.secondaryUserId()).isEqualTo("usr_secondary"); - assertThat(result.verificationToken()).isNotBlank(); - assertThat(result.expiresAt()).isEqualTo(Instant.parse("2026-03-18T00:30:00Z")); - verify(mergeRequestRepository).save(any(AccountMergeRequest.class)); - } - - @Test - void verify_marksRequestVerifiedWhenTokenMatches() throws Exception { - UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); - UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null); - AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); - - given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); - given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); - given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); - given(passwordEncoder.matches("raw-token", "encoded")).willReturn(true); - given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); - - service.verify("usr_primary", 7L, "raw-token"); - - assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_VERIFIED); - verify(mergeRequestRepository).save(request); - } - - @Test - void confirm_migratesBindingsRolesTokensAndMemberships() throws Exception { - UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); - UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null); - AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); - request.setStatus(AccountMergeRequest.STATUS_VERIFIED); - Role role = mock(Role.class); - given(role.getCode()).willReturn("AUDITOR"); - UserRoleBinding secondaryRole = new UserRoleBinding("usr_secondary", role); - IdentityBinding binding = new IdentityBinding("usr_secondary", "github", "gh_123", "secondary"); - ApiToken token = new ApiToken("usr_secondary", "cli", "sk_123", "hash", "[]"); - NamespaceMember secondaryMembership = new NamespaceMember(1L, "usr_secondary", NamespaceRole.ADMIN); - - given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); - given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); - given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); - given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); - given(identityBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(binding)); - given(apiTokenRepository.findByUserId("usr_secondary")).willReturn(List.of(token)); - given(userRoleBindingRepository.findByUserId("usr_primary")).willReturn(List.of()); - given(userRoleBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(secondaryRole)); - given(namespaceMemberRepository.findByUserId("usr_secondary")).willReturn(List.of(secondaryMembership)); - given(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_primary")).willReturn(Optional.empty()); - given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); - given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.empty()); - - service.confirm("usr_primary", 7L); - - assertThat(binding.getUserId()).isEqualTo("usr_primary"); - assertThat(token.getUserId()).isEqualTo("usr_primary"); - assertThat(token.getSubjectId()).isEqualTo("usr_primary"); - assertThat(secondaryMembership.getUserId()).isEqualTo("usr_primary"); - assertThat(secondary.getStatus()).isEqualTo(com.iflytek.skillhub.domain.user.UserStatus.MERGED); - assertThat(secondary.getMergedToUserId()).isEqualTo("usr_primary"); - assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_COMPLETED); - assertThat(request.getCompletedAt()).isEqualTo(Instant.parse("2026-03-18T00:00:00Z")); - assertThat(request.getVerificationToken()).isNull(); - verify(userRoleBindingRepository).save(any(UserRoleBinding.class)); - verify(userRoleBindingRepository).deleteAll(List.of(secondaryRole)); - } - - @Test - void verify_rejectsInvalidToken() throws Exception { - AccountMergeRequest request = request("usr_primary", "usr_secondary", "encoded"); - given(mergeRequestRepository.findByIdAndPrimaryUserId(7L, "usr_primary")).willReturn(Optional.of(request)); - given(passwordEncoder.matches("bad-token", "encoded")).willReturn(false); - - assertThatThrownBy(() -> service.verify("usr_primary", 7L, "bad-token")) - .isInstanceOf(AuthFlowException.class) - .hasMessageContaining("error.auth.merge.invalidToken"); - - verify(identityBindingRepository, never()).saveAll(any()); - } - - private AccountMergeRequest request(String primaryUserId, String secondaryUserId, String token) throws Exception { - AccountMergeRequest request = new AccountMergeRequest( - primaryUserId, - secondaryUserId, - token, - Instant.now(clock).plusSeconds(600) - ); - Field idField = AccountMergeRequest.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(request, 7L); - return request; - } -} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 44a66da4..0b2db6b4 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -765,26 +765,9 @@ "submit": "Update Password" }, "accounts": { - "initiateTitle": "Initiate Account Merge", - "initiateDesc": "Enter the secondary account identifier. Supports local username or `provider:subject` format for external identities.", - "secondaryLabel": "Secondary Identifier", - "secondaryPlaceholder": "e.g.: other_user or github:123456", - "initiating": "Initiating...", - "initiate": "Initiate Merge", - "initiateSuccess": "Merge request created, secondary={{secondaryUserId}}", - "initiateError": "Failed to initiate merge", - "verifyTitle": "Verify & Complete Merge", - "verifyDesc": "Complete token verification first, then confirm to execute data migration.", - "mergeRequestId": "Merge Request ID", - "verificationToken": "Verification Token", - "verifying": "Verifying...", - "verify": "Complete Merge", - "verifySuccess": "Verification successful, confirm to execute the merge", - "verifyError": "Merge verification failed", - "confirming": "Confirming...", - "confirm": "Confirm & Complete Merge", - "confirmSuccess": "Account merge completed", - "confirmError": "Merge confirmation failed" + "unavailableTitle": "Account merging is temporarily unavailable", + "unavailableDescription": "The previous flow could not independently verify control of both accounts, so it has been disabled during a security redesign.", + "unavailableOperatorAction": "Do not ask an administrator to complete a merge in the database. Keep using the accounts separately until the secure flow is available." }, "namespace": { "notFound": "Namespace not found", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index a707ad3d..4acf1011 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -765,26 +765,9 @@ "submit": "更新密码" }, "accounts": { - "initiateTitle": "发起账号合并", - "initiateDesc": "输入 secondary 账号标识。支持本地用户名,或 `provider:subject` 格式的外部身份。", - "secondaryLabel": "Secondary 标识", - "secondaryPlaceholder": "例如:other_user 或 github:123456", - "initiating": "发起中...", - "initiate": "发起合并", - "initiateSuccess": "已创建合并请求,secondary={{secondaryUserId}}", - "initiateError": "发起合并失败", - "verifyTitle": "验证并完成合并", - "verifyDesc": "先完成 token 验证,再单独确认执行数据迁移。", - "mergeRequestId": "Merge Request ID", - "verificationToken": "Verification Token", - "verifying": "验证中...", - "verify": "完成合并", - "verifySuccess": "验证成功,确认后将执行正式合并", - "verifyError": "验证合并失败", - "confirming": "确认中...", - "confirm": "确认并完成合并", - "confirmSuccess": "账号合并已完成", - "confirmError": "确认合并失败" + "unavailableTitle": "账号合并暂时不可用", + "unavailableDescription": "旧流程无法分别证明两个账号的控制权,因此在安全重构完成前已被停用。", + "unavailableOperatorAction": "请勿让管理员通过数据库手工完成合并。在安全流程上线前,请继续分别使用两个账号。" }, "namespace": { "notFound": "命名空间不存在", diff --git a/web/src/pages/settings/accounts.test.ts b/web/src/pages/settings/accounts.test.ts index 3b62220a..d09e2456 100644 --- a/web/src/pages/settings/accounts.test.ts +++ b/web/src/pages/settings/accounts.test.ts @@ -1,3 +1,7 @@ +/** @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react' +import { createElement } from 'react' import { describe, expect, it, vi } from 'vitest' vi.mock('react-i18next', async () => { @@ -10,36 +14,17 @@ vi.mock('react-i18next', async () => { } }) -vi.mock('@/features/auth/use-account-merge', () => ({ - useInitiateAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }), - useVerifyAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }), - useConfirmAccountMerge: () => ({ mutateAsync: vi.fn(), isPending: false }), -})) - -vi.mock('@/shared/lib/error-display', () => ({ - truncateErrorMessage: (v: string) => v, -})) - -vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/card', () => ({ - Card: ({ children }: { children: unknown }) => children, - CardContent: ({ children }: { children: unknown }) => children, - CardDescription: ({ children }: { children: unknown }) => children, - CardHeader: ({ children }: { children: unknown }) => children, - CardTitle: ({ children }: { children: unknown }) => children, -})) - -vi.mock('@/shared/ui/input', () => ({ - Input: () => null, -})) - import { AccountSettingsPage } from './accounts' describe('AccountSettingsPage', () => { - it('exports a named component function', () => { - expect(typeof AccountSettingsPage).toBe('function') + it('shows the temporary isolation notice without legacy merge controls', () => { + const { container } = render(createElement(AccountSettingsPage)) + + expect(screen.getByText('accounts.unavailableTitle')).toBeTruthy() + expect(screen.getByText('accounts.unavailableDescription')).toBeTruthy() + expect(screen.getByText('accounts.unavailableOperatorAction')).toBeTruthy() + expect(container.querySelector('form')).toBeNull() + expect(container.querySelector('input')).toBeNull() + expect(container.querySelector('button')).toBeNull() }) }) diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index fb13a933..e97b0483 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,138 +1,25 @@ -import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useConfirmAccountMerge, useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' -import { truncateErrorMessage } from '@/shared/lib/error-display' -import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' -import { Input } from '@/shared/ui/input' /** - * Account linking settings page for the multi-step account merge workflow. - * The route intentionally keeps all three steps visible because operators often - * need to paste ids or tokens across systems while completing the merge. + * Account merge is intentionally unavailable until the platform can prove independent control of + * both accounts. Keep the settings route so existing links remain valid, but do not render any + * legacy identifier, token, verification, or confirmation controls. */ export function AccountSettingsPage() { const { t } = useTranslation() - const [secondaryIdentifier, setSecondaryIdentifier] = useState('') - const [mergeRequestId, setMergeRequestId] = useState('') - const [verificationToken, setVerificationToken] = useState('') - const [statusMessage, setStatusMessage] = useState('') - - const initiateMutation = useInitiateAccountMerge() - const verifyMutation = useVerifyAccountMerge() - const confirmMutation = useConfirmAccountMerge() - - /** - * Starts the merge flow and surfaces the request id plus verification token - * returned by the backend for the following steps. - */ - async function handleInitiate(event: React.FormEvent) { - event.preventDefault() - setStatusMessage('') - try { - const result = await initiateMutation.mutateAsync({ secondaryIdentifier }) - setMergeRequestId(String(result.mergeRequestId)) - setVerificationToken(result.verificationToken) - setStatusMessage(t('accounts.initiateSuccess', { secondaryUserId: result.secondaryUserId })) - } catch (error) { - setStatusMessage( - truncateErrorMessage(error instanceof Error ? error.message : t('accounts.initiateError')) ?? t('accounts.initiateError'), - ) - } - } - - /** - * Verifies ownership of the secondary account before the final merge step. - */ - async function handleVerify(event: React.FormEvent) { - event.preventDefault() - setStatusMessage('') - try { - await verifyMutation.mutateAsync({ - mergeRequestId: Number(mergeRequestId), - verificationToken, - }) - setStatusMessage(t('accounts.verifySuccess')) - } catch (error) { - setStatusMessage( - truncateErrorMessage(error instanceof Error ? error.message : t('accounts.verifyError')) ?? t('accounts.verifyError'), - ) - } - } - - /** - * Finalizes the merge after verification has succeeded. - */ - async function handleConfirm() { - setStatusMessage('') - try { - await confirmMutation.mutateAsync({ mergeRequestId: Number(mergeRequestId) }) - setStatusMessage(t('accounts.confirmSuccess')) - } catch (error) { - setStatusMessage( - truncateErrorMessage(error instanceof Error ? error.message : t('accounts.confirmError')) ?? t('accounts.confirmError'), - ) - } - } return ( -

+
- {t('accounts.initiateTitle')} - {t('accounts.initiateDesc')} + {t('accounts.unavailableTitle')} + {t('accounts.unavailableDescription')} -
-
- - setSecondaryIdentifier(event.target.value)} - placeholder={t('accounts.secondaryPlaceholder')} - /> -
- -
-
-
- - - - {t('accounts.verifyTitle')} - {t('accounts.verifyDesc')} - - -
-
- - setMergeRequestId(event.target.value)} - /> -
-
- - setVerificationToken(event.target.value)} - /> -
- -
-
- -
- {statusMessage ?

{statusMessage}

: null} +

+ {t('accounts.unavailableOperatorAction')} +