mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(auth): isolate unsafe account merge flow
Keep the legacy routes fail-closed, remove the unsafe orchestration service, replace the UI controls with a security notice, and define the acceptance contract for the future safe merge flow. Closes #634 Parent: #628 Signed-off-by: ylhu16 <ylhu16@iflytek.com>
This commit is contained in:
parent
51ff9b99d2
commit
14da7d30ac
15 changed files with 637 additions and 750 deletions
|
|
@ -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 + 平台凭证)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:治理闭环 + 社交
|
||||
|
||||
|
|
|
|||
461
docs/22-secure-account-merge-acceptance-design.md
Normal file
461
docs/22-secure-account-merge-acceptance-design.md
Normal file
|
|
@ -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 不可用;不得以管理员手工操作作为替代方案。
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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: 可以通过以下方式获取帮助:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <p>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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=不支持的请求内容类型
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NamespaceRole> 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<LocalCredential> primaryCredential = localCredentialRepository.findByUserId(primaryUserId);
|
||||
Optional<LocalCredential> 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<IdentityBinding> bindings = identityBindingRepository.findByUserId(secondaryUserId);
|
||||
for (IdentityBinding binding : bindings) {
|
||||
binding.setUserId(primaryUserId);
|
||||
}
|
||||
identityBindingRepository.saveAll(bindings);
|
||||
}
|
||||
|
||||
private void migrateApiTokens(String primaryUserId, String secondaryUserId) {
|
||||
List<ApiToken> 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<String> primaryRoleCodes = new HashSet<>();
|
||||
for (UserRoleBinding binding : userRoleBindingRepository.findByUserId(primaryUserId)) {
|
||||
primaryRoleCodes.add(binding.getRole().getCode());
|
||||
}
|
||||
|
||||
List<UserRoleBinding> 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<NamespaceMember> secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId);
|
||||
for (NamespaceMember secondaryMembership : secondaryMemberships) {
|
||||
Optional<NamespaceMember> 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<LocalCredential> primaryCredential = localCredentialRepository.findByUserId(primaryUserId);
|
||||
Optional<LocalCredential> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "命名空间不存在",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<HTMLFormElement>) {
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Card className="glass-strong">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('accounts.initiateTitle')}</CardTitle>
|
||||
<CardDescription>{t('accounts.initiateDesc')}</CardDescription>
|
||||
<CardTitle>{t('accounts.unavailableTitle')}</CardTitle>
|
||||
<CardDescription>{t('accounts.unavailableDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={handleInitiate}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="secondary-identifier">{t('accounts.secondaryLabel')}</label>
|
||||
<Input
|
||||
id="secondary-identifier"
|
||||
value={secondaryIdentifier}
|
||||
onChange={(event) => setSecondaryIdentifier(event.target.value)}
|
||||
placeholder={t('accounts.secondaryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={initiateMutation.isPending}>
|
||||
{initiateMutation.isPending ? t('accounts.initiating') : t('accounts.initiate')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-strong">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('accounts.verifyTitle')}</CardTitle>
|
||||
<CardDescription>{t('accounts.verifyDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={handleVerify}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="merge-request-id">{t('accounts.mergeRequestId')}</label>
|
||||
<Input
|
||||
id="merge-request-id"
|
||||
value={mergeRequestId}
|
||||
onChange={(event) => setMergeRequestId(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="merge-token">{t('accounts.verificationToken')}</label>
|
||||
<Input
|
||||
id="merge-token"
|
||||
value={verificationToken}
|
||||
onChange={(event) => setVerificationToken(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={verifyMutation.isPending}>
|
||||
{verifyMutation.isPending ? t('accounts.verifying') : t('accounts.verify')}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-4">
|
||||
<Button type="button" onClick={handleConfirm} disabled={confirmMutation.isPending || !mergeRequestId}>
|
||||
{confirmMutation.isPending ? t('accounts.confirming') : t('accounts.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
{statusMessage ? <p className="mt-4 text-sm text-muted-foreground">{statusMessage}</p> : null}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('accounts.unavailableOperatorAction')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue