feat(auth): add secure account merging

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-31 17:07:43 +08:00
parent e7bde3e177
commit fd49ad9170
118 changed files with 15371 additions and 159 deletions

View file

@ -52,6 +52,17 @@ REDIS_PORT=6379
API_PORT=8080
WEB_PORT=80
SESSION_COOKIE_SECURE=false
# Account Merge requires an indexed Spring Session repository in a namespace
# that has never contained legacy sessions. This one-time cutover signs out
# existing web sessions. Enable only after the rollout procedure in
# docs/25-secure-account-merge-operations.md is complete.
SESSION_REDIS_NAMESPACE=skillhub:session:indexed-v1
SPRING_SESSION_REDIS_REPOSITORY_TYPE=indexed
# Use none only when the managed Redis service already supplies the required
# keyspace notification configuration and rejects CONFIG commands.
SPRING_SESSION_REDIS_CONFIGURE_ACTION=notify-keyspace-events
SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED=false
SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE=false
# Observability defaults require no Collector or tracing backend.
# Use json in container deployments when stdout is collected centrally.

View file

@ -65,6 +65,9 @@ jobs:
- name: Verify Binding V2 migration and concurrency on PostgreSQL
run: bash scripts/tests/identity-binding-v2-postgres-test.sh
- name: Verify Account Merge on PostgreSQL and Redis
run: bash scripts/tests/account-merge-integration-test.sh
- name: Start loopback identity provider for Identity Link
run: |
python3 scripts/tests/oauth2-mock-provider.py --port 18081 \

View file

@ -303,6 +303,15 @@ kubectl create secret docker-registry private-registry \
| `postgresql.architecture` | 架构模式 | `standalone` |
| `redis.enabled` | 启用内置 Redis | `true` |
| `redis.architecture` | 架构模式 | `standalone` |
| `session.redisNamespace` | indexed Session 的隔离 namespace首次启用时必须与旧值不同 | `skillhub:session:indexed-v1` |
| `session.repositoryType` | Spring Session Redis repository账号合并要求 `indexed` | `indexed` |
| `session.configureAction` | Redis keyspace notification 配置动作 | `notify-keyspace-events` |
| `auth.accountMerge.enabled` | 安全账号合并功能开关;完成两阶段启用后才可打开 | `false` |
| `auth.accountMerge.sessionCutoverComplete` | 已完成旧 Session namespace 切换的运维确认 | `false` |
账号合并不能在切换到 indexed Session 的同一次滚动发布中直接启用。旧 Session 不会
可靠补建 principal index必须更换 namespace 使其统一失效,再确认 cutover。完整步骤见
[`docs/25-secure-account-merge-operations.md`](../../docs/25-secure-account-merge-operations.md)。
#### 数据库架构支持边界

View file

@ -44,6 +44,11 @@ data:
# Session
session-cookie-secure: {{ or .Values.session.cookieSecure (not (empty .Values.ingress.tls)) .Values.ingress.certManager.enabled | quote }}
session-redis-namespace: {{ .Values.session.redisNamespace | quote }}
session-repository-type: {{ .Values.session.repositoryType | quote }}
session-redis-configure-action: {{ .Values.session.configureAction | quote }}
auth-account-merge-enabled: {{ .Values.auth.accountMerge.enabled | quote }}
auth-account-merge-session-cutover-complete: {{ .Values.auth.accountMerge.sessionCutoverComplete | quote }}
# Public URL and authentication
public-base-url: {{ .Values.publicBaseUrl | quote }}

View file

@ -278,6 +278,31 @@ spec:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: session-cookie-secure
- name: SPRING_SESSION_REDIS_REPOSITORY_TYPE
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: session-repository-type
- name: SESSION_REDIS_NAMESPACE
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: session-redis-namespace
- name: SPRING_SESSION_REDIS_CONFIGURE_ACTION
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: session-redis-configure-action
- name: SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-account-merge-enabled
- name: SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: auth-account-merge-session-cutover-complete
# Public URL and authentication
- name: SKILLHUB_PUBLIC_BASE_URL

View file

@ -6,6 +6,12 @@
{{- if and .Values.auth.direct.enabled (not .Values.auth.direct.provider) -}}
{{- fail "auth.direct.enabled=true requires auth.direct.provider" -}}
{{- end -}}
{{- if and .Values.auth.accountMerge.enabled (ne .Values.session.repositoryType "indexed") -}}
{{- fail "auth.accountMerge.enabled=true requires session.repositoryType=indexed" -}}
{{- end -}}
{{- if and .Values.auth.accountMerge.enabled (not .Values.auth.accountMerge.sessionCutoverComplete) -}}
{{- fail "auth.accountMerge.enabled=true requires auth.accountMerge.sessionCutoverComplete=true after rotating the Spring Session namespace" -}}
{{- end -}}
{{- if .Values.auth.cas.enabled -}}
{{- if not .Values.auth.cas.serverUrl -}}
{{- fail "auth.cas.enabled=true requires auth.cas.serverUrl" -}}

View file

@ -21,7 +21,7 @@
"auth": {
"type": "object",
"additionalProperties": false,
"required": ["direct", "cas"],
"required": ["direct", "accountMerge", "cas"],
"properties": {
"direct": {
"type": "object",
@ -32,6 +32,15 @@
"provider": { "type": "string" }
}
},
"accountMerge": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "sessionCutoverComplete"],
"properties": {
"enabled": { "type": "boolean" },
"sessionCutoverComplete": { "type": "boolean" }
}
},
"cas": {
"type": "object",
"additionalProperties": false,
@ -158,8 +167,13 @@
"session": {
"type": "object",
"additionalProperties": false,
"required": ["cookieSecure"],
"properties": { "cookieSecure": { "type": "boolean" } }
"required": ["cookieSecure", "redisNamespace", "repositoryType", "configureAction"],
"properties": {
"cookieSecure": { "type": "boolean" },
"redisNamespace": { "type": "string", "minLength": 1 },
"repositoryType": { "enum": ["default", "indexed"] },
"configureAction": { "enum": ["notify-keyspace-events", "none"] }
}
},
"bootstrapAdmin": {
"type": "object",

View file

@ -21,6 +21,11 @@ auth:
direct:
enabled: true
provider: local
accountMerge:
# Enable only after every server pod uses indexed sessions and the
# documented legacy-session transition gate has completed.
enabled: false
sessionCutoverComplete: false
cas:
enabled: false
providerCode: cas-main
@ -82,6 +87,9 @@ s3:
# ============================================================================
session:
cookieSecure: false
redisNamespace: skillhub:session:indexed-v1
repositoryType: indexed
configureAction: notify-keyspace-events
# ============================================================================
# Bootstrap 管理员

View file

@ -69,6 +69,9 @@ services:
SPRING_DATA_REDIS_TIMEOUT:
SPRING_DATA_REDIS_CLIENT_NAME:
SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST:
SESSION_REDIS_NAMESPACE: ${SESSION_REDIS_NAMESPACE:-skillhub:session:indexed-v1}
SPRING_SESSION_REDIS_REPOSITORY_TYPE: ${SPRING_SESSION_REDIS_REPOSITORY_TYPE:-indexed}
SPRING_SESSION_REDIS_CONFIGURE_ACTION: ${SPRING_SESSION_REDIS_CONFIGURE_ACTION:-notify-keyspace-events}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-}
@ -88,6 +91,8 @@ services:
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
SKILLHUB_SECURITY_SCANNER_MODE: upload
SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false}
SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED: ${SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED:-false}
SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE: ${SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE:-false}
SKILLHUB_AUTH_CAS_ENABLED: ${SKILLHUB_AUTH_CAS_ENABLED:-false}
SKILLHUB_AUTH_CAS_PROVIDER_CODE: ${SKILLHUB_AUTH_CAS_PROVIDER_CODE:-cas}
SKILLHUB_AUTH_CAS_DISPLAY_NAME: ${SKILLHUB_AUTH_CAS_DISPLAY_NAME:-CAS}

View file

@ -167,6 +167,31 @@ spec:
configMapKeyRef:
name: skillhub-config
key: session-cookie-secure
- name: SPRING_SESSION_REDIS_REPOSITORY_TYPE
valueFrom:
configMapKeyRef:
name: skillhub-config
key: session-repository-type
- name: SESSION_REDIS_NAMESPACE
valueFrom:
configMapKeyRef:
name: skillhub-config
key: session-redis-namespace
- name: SPRING_SESSION_REDIS_CONFIGURE_ACTION
valueFrom:
configMapKeyRef:
name: skillhub-config
key: session-redis-configure-action
- name: SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED
valueFrom:
configMapKeyRef:
name: skillhub-config
key: auth-account-merge-enabled
- name: SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE
valueFrom:
configMapKeyRef:
name: skillhub-config
key: auth-account-merge-session-cutover-complete
# CAS 2.0/3.0 browser login
- name: SKILLHUB_AUTH_CAS_ENABLED

View file

@ -46,6 +46,13 @@ data:
# Session 配置
# HTTP 环境设为 falseHTTPS 环境设为 true
session-cookie-secure: "false"
# 账号合并依赖按稳定 userId 建立的 Spring Session principal index。
session-redis-namespace: skillhub:session:indexed-v1
session-repository-type: indexed
session-redis-configure-action: notify-keyspace-events
# 首次升级先保持关闭;完成 Session 索引过渡后再启用。
auth-account-merge-enabled: "false"
auth-account-merge-session-cutover-complete: "false"
# CAS 2.0/3.0 登录(默认关闭)
# service-url 必须是浏览器可访问的精确回调地址,且 provider code

View file

@ -269,6 +269,9 @@ PR 6 开始前必须建立所有 userId 引用的迁移清单,并分类:
任何未分类的 userId 外键、字符串引用或 JSON 引用都阻塞发布。不能只迁移旧
`AccountMergeService` 已知的几张表就宣称完成。
实现清单见
[`24-account-merge-user-reference-inventory.md`](./24-account-merge-user-reference-inventory.md)。
## 8. 事务、Session 与跨存储一致性
### 8.1 PostgreSQL 事务
@ -362,6 +365,9 @@ code、Ticket、SAML assertion、API Token hash 或完整上游响应。
## 11. 升级、启用与回滚
可执行的部署顺序、配置字段、观察方式和回滚步骤见
[`25-secure-account-merge-operations.md`](./25-secure-account-merge-operations.md)。
### 11.1 旧请求
- 旧 `PENDING`/`VERIFIED` 请求不携带可信的次账号 proof不能转换为可确认的新 intent。

View file

@ -0,0 +1,103 @@
# Account Merge user reference inventory
> Status: implementation gate for [#662](https://github.com/iflytek/skillhub/issues/662)
>
> Parent design:
> [`22-secure-account-merge-acceptance-design.md`](./22-secure-account-merge-acceptance-design.md)
>
> Baseline: `big-main@e7bde3e177142d24b99e438d78b11f438bcb80f1`
## 1. Purpose
Safe Account Merge cannot be implemented by updating only authentication tables. This inventory
classifies every persisted or externally cached platform-user reference found in the Flyway schema
and server code before PR 6 starts changing data.
The four classifications are:
1. **Current ownership** — migrate to the primary account.
2. **Current authorization** — migrate, merge, block, or revoke according to an explicit rule.
3. **Historical fact** — retain the secondary user ID; history is not rewritten.
4. **Derived/transient state** — rebuild, invalidate, close, or let expire under an explicit rule.
Any new user reference added before #662 merges must be added here and covered by preview, confirm,
or a documented preservation rule.
## 2. Account and authentication references
| Store / column | Classification | Preview and confirm rule |
|---|---|---|
| `user_account.id` | Account identity | Keep both rows. Lock both in stable ID order. The secondary row becomes `MERGED`; it is never deleted or reused. |
| `user_account.merged_to_user_id` | Account lineage | Set only on the secondary row, in the same transaction as all migrations. It must reference the primary account and cannot form a chain or cycle. |
| `identity_binding.user_id` | Current authorization | Move only `ACTIVE` bindings. Block when the primary already has another ACTIVE binding for the same Provider instance. Keep REVOKED bindings on the secondary account as historical security records. `identity_binding_subject` follows its binding and its uniqueness constraints remain authoritative. |
| `identity_binding.revoked_by` | Historical fact | Never rewrite. It records the actor who revoked a binding. |
| `local_credential.user_id` | Current authorization | If only the secondary has a local credential, move it. If both have one, retain the primary credential and delete the secondary credential inside the merge transaction; never copy the password hash or keep it usable for both accounts. The preview must state which outcome applies. |
| `api_token.user_id` and USER `subject_id` | Current authorization | Revoke every active secondary token in place. Do not change ownership or subject. Historical revoked tokens remain attached to the secondary account. Preview exposes only token name, prefix, and count. |
| `user_role_binding.user_id` | Current authorization | Any persisted non-default platform role on the secondary account blocks merge. Do not union roles. The default `USER` role is projected by `PlatformRoleDefaults` and is not migrated. |
| `identity_link_request.primary_user_id` | Current security workflow | Any active secondary Identity Link request blocks merge. Completed, expired, or cancelled requests remain unchanged as history. |
| legacy `account_merge_request.primary_user_id` / `secondary_user_id` | Historical fact | Never convert, complete, delete, or attach these rows to a new intent. The legacy endpoints remain fail-closed. |
| new `account_merge_intent.primary_user_id` / `secondary_user_id` | Current security workflow | Owned by the new flow. The secondary ID stays NULL until independent authentication succeeds. Completed intents remain as audit evidence. |
| new session-revocation task `user_id` | Derived/transient state | Enqueue the secondary user in the same PostgreSQL transaction. Retry Redis deletion until complete; do not treat task insertion as equivalent to session deletion. |
## 3. Namespace, skill, and social references
| Store / column | Classification | Preview and confirm rule |
|---|---|---|
| `namespace_member.user_id` | Current authorization | If only the secondary is a MEMBER or ADMIN, move it. If both accounts are members, retain the higher role and delete the duplicate secondary membership. Any move that newly grants OWNER to the primary blocks merge. Existing last-owner invariants still apply. |
| `skill.owner_id` | Current ownership | Move every owned Skill to the primary account. Block when this would violate `(namespace_id, slug, owner_id)` uniqueness. |
| `skill_search_document.owner_id` | Derived state | Rebuild or update from the migrated Skill owner in the same release. It is not an independent source of truth. |
| `skill_star.user_id` | Current user state | Move secondary-only stars. If both accounts starred the same Skill, keep the primary row and delete the duplicate secondary row. Recompute `skill.star_count` for affected Skills. |
| `skill_rating.user_id` | Current user state | Move secondary-only ratings. If both accounts rated the same Skill, retain the primary rating and delete the secondary duplicate; the preview reports the discarded secondary score. Recompute rating count and average for affected Skills. |
| `skill_subscription.user_id` | Current user state | Move secondary-only subscriptions. If both accounts subscribed to the same Skill, retain the primary row and delete the duplicate secondary row. Recompute `skill.subscription_count` for affected Skills. |
## 4. Profile, notification, and temporary references
| Store / column | Classification | Preview and confirm rule |
|---|---|---|
| `user_profile_field_source.user_id` | Historical/profile provenance | Keep the primary profile and its provenance. Preserve secondary provenance on the secondary account; do not let a merge overwrite manually or administratively managed primary fields. |
| `profile_change_request.user_id` | Current workflow plus history | A PENDING secondary request blocks merge because it could mutate a merged account later. Completed/rejected/cancelled rows remain on the secondary account. |
| `profile_change_request.reviewer_id` | Historical fact | Never rewrite. |
| `password_reset_request.user_id` | Derived security state | Consume/invalidate every unconsumed secondary reset request inside the transaction. Keep consumed requests as history. |
| `password_reset_request.requested_by_user_id` | Historical fact | Never rewrite. |
| `notification.recipient_id` | Current user state | Move notification inbox rows to the primary account so unread/read history remains visible after consolidation. Embedded `body_json` is historical content and is not rewritten. |
| `notification_preference.user_id` | Current user state | Move secondary-only preferences. If both accounts define the same category/channel, retain the primary preference. |
| `user_notification.user_id` | Current user state | Move governance-notification inbox rows to the primary account. Embedded `body_json` remains unchanged. |
| in-memory `SseEmitterManager` key | Derived/transient state | Close all secondary emitters after commit. Never re-key a live emitter to the primary account. |
| Redis `DeviceCodeData.userId` | Derived security state | A stale authorized device code must not mint a token after merge. Token redemption rechecks that the account is ACTIVE; existing codes then fail closed and expire within their normal TTL. |
| Spring Session principal index | Derived security state | New and touched sessions must be indexed by the stable platform user ID. Merge enqueues deletion of every indexed secondary session. Account-status checking on every API request is the immediate guard while deletion retries. |
| Account Merge primary proof / browser state / session nonce | Derived security state | Store raw values only in the bound server session, with a short TTL and one-time consumption. Persist only hashes and timestamps. Never return or log raw proof/state/nonce. |
## 5. Historical actor references that must not move
The following columns describe who performed an action at that time. They continue to reference the
secondary account after merge:
- `audit_log.actor_user_id`
- `namespace.created_by`
- `skill.created_by`, `skill.updated_by`, `skill.hidden_by`
- `skill_version.created_by`, `skill_version.yanked_by`
- `skill_tag.created_by`
- `label_definition.created_by`
- `skill_label.created_by`
- `review_task.submitted_by`, `review_task.reviewed_by`
- `promotion_request.submitted_by`, `promotion_request.reviewed_by`
JSON audit details, notification bodies, domain-event payloads, request logs, and metrics are not
rewritten. Secrets and raw authentication proof remain prohibited in all of them.
## 6. Confirmation lock and consistency order
Confirmation uses this stable order:
1. lock Merge Intent;
2. lock primary and secondary `user_account` rows by ascending user ID;
3. recompute the complete preview and digest;
4. lock and migrate current authorization and ownership records;
5. revoke credentials/tokens and invalidate temporary security state;
6. mark the secondary account `MERGED`;
7. mark the intent `COMPLETED`, write audit/outbox evidence, and enqueue session revocation;
8. commit PostgreSQL;
9. close SSE connections and process Redis Session deletion idempotently.
Any unsupported or newly discovered current-state reference is a blocking conflict, not a reason to
silently leave data behind.

View file

@ -0,0 +1,199 @@
# 安全账号合并运维与发布手册
> 适用范围Issue [#662](https://github.com/iflytek/skillhub/issues/662) 的安全账号合并实现。
>
> 安全与验收规则以
> [`22-secure-account-merge-acceptance-design.md`](./22-secure-account-merge-acceptance-design.md)
> 为准;本文件只定义如何部署、启用、观察和回滚。
## 1. 发布边界
账号合并默认关闭。两个条件同时满足时,新流程才可用:
```text
SPRING_SESSION_REDIS_REPOSITORY_TYPE=indexed
SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE=true
SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED=true
```
应用在功能开启但 Spring Session 不是 indexed repository或没有确认 Session namespace
切换完成时拒绝启动。Helm 在对应条件不满足时也拒绝渲染。
以下三个旧接口无论开关状态如何都保持 `503 Service Unavailable`,不能作为回滚或兼容
入口:
```text
POST /api/v1/account/merge/initiate
POST /api/v1/account/merge/verify
POST /api/v1/account/merge/confirm
```
不要删除、改写或手工完成旧 `account_merge_request`。新流程只使用
`account_merge_intent` 和服务端 Session proof。
## 2. Redis 前置条件
### 2.1 Principal index
Spring Session 用 `PlatformPrincipal#getName()` 的稳定平台 `userId` 建立 principal index。
合并完成后,后台任务用该 index 找到并删除次账号的全部 Session。账号状态守卫会在每个
API 请求重新读取 `user_account`,所以 Redis 删除重试期间,已标记 `MERGED` 的账号也会
立即收到 401。
`default``indexed` repository 使用兼容的 Session hash key因此 indexed repository
能读取旧 Session但真实 Redis 验证表明,旧 repository 创建的 Session 即使更新
last-accessed 并再次保存,也不会自动补入 principal index。它会被当作“索引已经存在”
处理。旧 Session 因而不能被后台任务枚举。
安全升级必须更换 `SESSION_REDIS_NAMESPACE`,让所有旧 Session 一次性失效;不能依赖
访问预热、在线 Session 抽样或等待 TTL。模板为本次切换使用
`skillhub:session:indexed-v1`。如果现有部署已占用该 namespace必须选择一个从未使用的
新值。
### 2.2 Keyspace notification
模板默认:
```text
SPRING_SESSION_REDIS_CONFIGURE_ACTION=notify-keyspace-events
```
这允许 Spring Session 配置所需的 Redis keyspace notification。托管 Redis 如果禁止
`CONFIG`,必须先由运维平台配置相应通知,再使用:
```text
SPRING_SESSION_REDIS_CONFIGURE_ACTION=none
```
不能仅为了让应用启动而设为 `none`;必须先在目标 Redis 拓扑完成 Session 创建、按
principal 查询、过期和删除验证。Redis Cluster 还必须运行仓库中的
`RedisClusterIntegrationTest`,不能用单机 Redis 结果替代。
## 3. 两阶段启用
### 阶段 A切换 Session namespace
1. 备份 PostgreSQL并记录当前镜像和旧 `SESSION_REDIS_NAMESPACE`
2. 确认网关精确阻断三个旧接口;不要阻断新的 `/intents``/reauthenticate` 资源。
3. 选择一个本部署从未使用、且不与其他环境共享的新 namespace部署所有新 Pod
```text
SESSION_REDIS_NAMESPACE=skillhub:session:indexed-v1
SPRING_SESSION_REDIS_REPOSITORY_TYPE=indexed
SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE=false
SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED=false
```
4. 确认没有仍读写旧 namespace 的 Pod。切换会让现有 Web Session 全部退出,这是预期
的一次性安全迁移。
5. 重新登录,验证普通本地/OAuth/CAS 登录、`/api/v1/auth/me`、API Token、Namespace
和 Skill 访问。
6. 在目标 Redis 拓扑创建至少两个 Session确认可用稳定 userId 通过 principal index
找到,并验证删除、失败重试和最终清空。运行仓库中的真实 Redis 集成测试。
7. 使用已登录 Session 请求 `GET /api/v1/account/merge/capabilities`,确认
`data.enabled=false`
8. 上述项目全部通过后,才把
`SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE` 视为可设置为 `true`
旧 namespace 可以先保留到原 TTL 自然过期。若需要清理,只能在备份并确认未被其他环境
共享后删除旧 namespace 的精确 keys禁止 `FLUSHDB``FLUSHALL` 或宽泛 key 删除。
### 阶段 B启用新流程
1. 保持阶段 A 的新 Redis namespace 和 `indexed` repository。
2. 同时设置:
```text
SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE=true
SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED=true
```
然后滚动部署所有 Pod。
3. 确认没有关闭功能的旧 Pod混跑会导致请求随机得到不可用结果。
4. 使用已登录 Session 请求 capabilities确认 `data.enabled=true` 且认证方法与已启用
Provider 一致。
5. 在测试账号上完成双账号验证矩阵:主/次账号分别重新认证、preview、confirm、次账号
Session 401、次账号 Token 401、主账号 Session/Token 正常。
6. 检查 `account_merge_session_revocation` 没有长期停留的 `PENDING`/`PROCESSING`
任务,并检查账号合并及 Session 撤销重试指标。
Docker Compose 对应:
```text
SESSION_REDIS_NAMESPACE=skillhub:session:indexed-v1
SPRING_SESSION_REDIS_REPOSITORY_TYPE=indexed
SPRING_SESSION_REDIS_CONFIGURE_ACTION=notify-keyspace-events
SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE=false|true
SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED=false|true
```
Helm 对应:
```yaml
session:
redisNamespace: skillhub:session:indexed-v1
repositoryType: indexed
configureAction: notify-keyspace-events
auth:
accountMerge:
enabled: false # 阶段 B 才改为 true
sessionCutoverComplete: false # 阶段 B 验收通过后才改为 true
```
Kustomize base 对应 ConfigMap keys
```text
session-redis-namespace
session-repository-type
session-redis-configure-action
auth-account-merge-enabled
auth-account-merge-session-cutover-complete
```
## 4. 升级兼容性
- Flyway V50 只新增表、索引和约束;不删除旧表或旧字段。
- `PlatformPrincipal` 的 record 字段和 Java 序列化数据保持不变,只新增稳定
`Principal#getName()` 行为。
- 旧 Session hash 可由 indexed repository 读取,但不会可靠补建 principal index阶段 A
必须通过新 namespace 让它们失效。
- 旧 `account_merge_request` 只保留审计事实,不能转换为新 intent。
- 新旧 Pod 共存期间功能必须关闭;所有新 Pod 就绪且旧 Session 收敛后再启用。
- 已完成的合并不可自动拆分,不能把镜像或数据库 schema 回滚等同于账号恢复。
## 5. 关闭与回滚
### 5.1 只关闭功能
先设置 `SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED=false` 并完成全部 Pod 滚动。关闭后:
- 不能创建、认证、预览或确认新 intent。
- 已完成合并保持有效。
- 未完成 intent 保留为审计数据,并按其原有 TTL 失效;不要手工改为完成。
- 继续保留 indexed Session 配置、V50 表和每请求账号状态守卫。
### 5.2 回滚应用镜像
1. 先在网关阻断整个 `/api/v1/account/merge/*`,再启动可能包含旧不安全流程的镜像。
2. 不回滚或删除 V50 schemaadditive 表可以由旧应用忽略。
3. 不把 `MERGED` 次账号改回 `ACTIVE`,不恢复已撤销 Token不复制或重绑凭据。
4. 如确需拆分账号,必须走独立、人工审核、带备份和数据清单的数据恢复流程。
5. 回滚完成后验证普通登录、Token、Namespace、Skill、Redis Session 和旧接口阻断。
如果网关无法在镜像回滚前可靠阻断旧路径,则该镜像回滚不安全,应停止。
## 6. 发布验收证据
进入 `main` 前至少保留以下可观察证据:
- 精确 feature SHA、`big-main` SHA 和 OCI
`org.opencontainers.image.revision`
- PostgreSQL 16 migration、事务回滚、冲突和并发测试结果。
- 目标 Redis 拓扑上的 principal index、全部删除、失败重试和最终清空结果。
- 次账号旧 Web Session 与 API Token 均返回 401主账号不受影响。
- Browser Provider callback replay、proof/intent 过期和 preview stale 结果。
- 日志、响应、URL、审计和指标不含密码、raw proof、Session ID/nonce、OAuth token、
CAS ticket 或高基数用户标识。
- 隔离测试资源已精确清理,既有服务测试前后健康且未重启宿主机。
缺少任一证据时保持功能关闭,不以管理员手工改库或“页面能打开”替代。

View file

@ -326,14 +326,18 @@ Since **SkillHub Server v0.2.12**, public skills support anonymous search and in
## 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:
secondary accounts, so it remains permanently isolated. Administrators enable the safe
double-reauthentication flow in two stages. A temporarily unavailable page means that this
deployment has not completed the indexed-session namespace cutover and feature-gate checks.
- 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`.
- Administrators must follow the
[secure account merge operations runbook](../../25-secure-account-merge-operations.md)
instead of enabling the feature flag directly.
Normal login, existing identity binding lookup, namespace operations, and skill operations are not
affected.

View file

@ -325,14 +325,18 @@ xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir"
## Q: 为什么账号合并页面显示暂时不可用?
A: 旧的账号合并流程不能分别证明主账号和次账号的控制权,因此已被安全隔离。在新的
双重重新认证流程上线前:
A: 旧的账号合并流程不能分别证明主账号和次账号的控制权,因此已被永久隔离。安全的
双重重新认证流程由管理员分阶段启用;页面显示暂时不可用表示当前部署尚未完成
indexed Session namespace 切换和功能开关门禁。
- 请继续分别使用两个账号。
- 不要让管理员直接修改数据库、移动 identity binding、角色、namespace membership、
本地凭据或 API Token。
- 已存在的 `account_merge_request` 记录会被保留,但不会继续执行。
- 如果 API 客户端仍调用旧接口,已认证请求会收到 `503 Service Unavailable`
- 管理员应按
[`安全账号合并运维与发布手册`](../25-secure-account-merge-operations.md)
完成两阶段启用,不应直接打开功能开关。
这不会影响普通登录、身份绑定读取、Namespace 或 Skill 操作。

View file

@ -0,0 +1,159 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_ID="skillhub-account-merge-$$"
POSTGRES_CONTAINER="${RUN_ID}-postgres"
REDIS_CONTAINER="${RUN_ID}-redis"
NETWORK="${RUN_ID}-network"
POSTGRES_USER="account_merge"
POSTGRES_PASSWORD="account-merge-test-password"
POSTGRES_DB="account_merge"
MAVEN_CACHE_DIR="${MAVEN_CACHE_DIR:-${HOME}/.m2}"
TEST_CLASSES="AccountMergeIntentMigrationPostgresTest,\
AccountMergePostgresIntegrationTest,\
AccountMergeSessionRevocationRepositoryPostgresTest,\
AccountMergeSessionRedisIntegrationTest"
log() {
printf '[account-merge] %s\n' "$*"
}
cleanup() {
exit_code="$?"
if [[ "${exit_code}" -ne 0 ]]; then
log "failed with exit code ${exit_code}"
docker ps -a \
--filter "label=skillhub.test.run=${RUN_ID}" \
--format 'resource={{.Names}} status={{.Status}}' || true
docker logs "${POSTGRES_CONTAINER}" 2>&1 || true
docker logs "${REDIS_CONTAINER}" 2>&1 || true
fi
docker rm -f \
"${POSTGRES_CONTAINER}" \
"${REDIS_CONTAINER}" >/dev/null 2>&1 || true
docker network rm "${NETWORK}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
log "creating isolated Docker network ${NETWORK}"
docker network create \
--label "skillhub.test.run=${RUN_ID}" \
"${NETWORK}" >/dev/null
log "starting isolated PostgreSQL and Redis"
docker run -d \
--name "${POSTGRES_CONTAINER}" \
--label "skillhub.test.run=${RUN_ID}" \
--network "${NETWORK}" \
--memory=1g \
--cpus=1 \
-e "POSTGRES_USER=${POSTGRES_USER}" \
-e "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" \
-e "POSTGRES_DB=${POSTGRES_DB}" \
-p 127.0.0.1::5432 \
postgres:16-alpine >/dev/null
docker run -d \
--name "${REDIS_CONTAINER}" \
--label "skillhub.test.run=${RUN_ID}" \
--network "${NETWORK}" \
--memory=256m \
--cpus=0.5 \
-p 127.0.0.1::6379 \
redis:7-alpine >/dev/null
log "waiting for PostgreSQL and Redis readiness"
postgres_ready="false"
redis_ready="false"
for _ in $(seq 1 60); do
if [[ "${postgres_ready}" != "true" ]] \
&& [[ "$(docker exec "${POSTGRES_CONTAINER}" \
cat /proc/1/comm 2>/dev/null || true)" == "postgres" ]] \
&& docker exec "${POSTGRES_CONTAINER}" \
pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
>/dev/null 2>&1; then
postgres_ready="true"
fi
if [[ "${redis_ready}" != "true" ]] \
&& [[ "$(docker exec "${REDIS_CONTAINER}" \
cat /proc/1/comm 2>/dev/null || true)" == "redis-server" ]] \
&& docker exec "${REDIS_CONTAINER}" redis-cli ping \
2>/dev/null | grep -Fxq PONG; then
redis_ready="true"
fi
if [[ "${postgres_ready}" == "true" \
&& "${redis_ready}" == "true" ]]; then
break
fi
sleep 1
done
if [[ "${postgres_ready}" != "true" \
|| "${redis_ready}" != "true" ]]; then
log "dependencies did not become ready"
exit 1
fi
run_tests() {
java_version=""
if command -v java >/dev/null 2>&1; then
java_version="$(java -version 2>&1)"
fi
if [[ "${java_version}" == *'"21.'* ]]; then
postgres_port="$(docker port "${POSTGRES_CONTAINER}" 5432/tcp \
| sed -n 's/.*://p')"
redis_port="$(docker port "${REDIS_CONTAINER}" 6379/tcp \
| sed -n 's/.*://p')"
if [[ -z "${postgres_port}" || -z "${redis_port}" ]]; then
log "Docker did not publish required loopback ports"
return 1
fi
log "running integration tests with host Java 21"
(
cd "${REPO_ROOT}/server"
IDENTITY_BINDING_V2_POSTGRES_URL="jdbc:postgresql://127.0.0.1:${postgres_port}/${POSTGRES_DB}" \
IDENTITY_BINDING_V2_POSTGRES_USERNAME="${POSTGRES_USER}" \
IDENTITY_BINDING_V2_POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \
REDIS_TEST_HOST="127.0.0.1" \
REDIS_TEST_PORT="${redis_port}" \
MAVEN_OPTS="-Xmx2g -XX:MaxMetaspaceSize=512m" \
./mvnw \
-pl skillhub-app \
-am \
"-Dtest=${TEST_CLASSES}" \
-Dsurefire.failIfNoSpecifiedTests=false \
test
)
return
fi
log "running integration tests with containerized Java 21"
mkdir -p "${MAVEN_CACHE_DIR}"
docker run --rm \
--name "${RUN_ID}-java" \
--label "skillhub.test.run=${RUN_ID}" \
--network "${NETWORK}" \
--memory=4g \
--cpus=2 \
--user "$(id -u):$(id -g)" \
-e MAVEN_USER_HOME=/tmp/skillhub-maven-home/.m2 \
-e MAVEN_OPTS="-Xmx2g -XX:MaxMetaspaceSize=512m" \
-e "IDENTITY_BINDING_V2_POSTGRES_URL=jdbc:postgresql://${POSTGRES_CONTAINER}:5432/${POSTGRES_DB}" \
-e "IDENTITY_BINDING_V2_POSTGRES_USERNAME=${POSTGRES_USER}" \
-e "IDENTITY_BINDING_V2_POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" \
-e "REDIS_TEST_HOST=${REDIS_CONTAINER}" \
-e REDIS_TEST_PORT=6379 \
-v "${REPO_ROOT}:/workspace" \
-v "${MAVEN_CACHE_DIR}:/tmp/skillhub-maven-home/.m2" \
-w /workspace/server \
eclipse-temurin:21-jdk-alpine \
./mvnw \
-Dmaven.repo.local=/tmp/skillhub-maven-home/.m2/repository \
-pl skillhub-app \
-am \
"-Dtest=${TEST_CLASSES}" \
-Dsurefire.failIfNoSpecifiedTests=false \
test
}
run_tests
log "account merge PostgreSQL and Redis integration tests passed"

View file

@ -0,0 +1,32 @@
package com.iflytek.skillhub.config;
import com.iflytek.skillhub.auth.merge.AccountMergeProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.stereotype.Component;
/**
* Fails startup when safe account merge is enabled without an indexed Spring
* Session repository capable of deleting every secondary-user session.
*/
@Component
@ConditionalOnProperty(
prefix = "skillhub.auth.account-merge",
name = "enabled",
havingValue = "true")
public class AccountMergeSessionRevocationReadiness {
public AccountMergeSessionRevocationReadiness(
FindByIndexNameSessionRepository<?> sessionRepository,
AccountMergeProperties properties) {
if (!properties.isSessionCutoverComplete()) {
throw new IllegalStateException(
"Safe account merge requires a completed "
+ "Spring Session namespace cutover; set "
+ "skillhub.auth.account-merge."
+ "session-cutover-complete=true only "
+ "after every legacy session has been "
+ "invalidated");
}
}
}

View file

@ -1,7 +1,19 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.AccountMergeBrowserAuthenticationRequest;
import com.iflytek.skillhub.dto.AccountMergeBrowserStartResponse;
import com.iflytek.skillhub.dto.AccountMergeCapabilitiesResponse;
import com.iflytek.skillhub.dto.AccountMergeCompletionResponse;
import com.iflytek.skillhub.dto.AccountMergeConfirmRequest;
import com.iflytek.skillhub.dto.AccountMergeCredentialAuthenticationRequest;
import com.iflytek.skillhub.dto.AccountMergeIntentResponse;
import com.iflytek.skillhub.dto.AccountMergeLocalReauthenticationRequest;
import com.iflytek.skillhub.dto.AccountMergePrimaryProofResponse;
import com.iflytek.skillhub.dto.AccountMergePreviewResponse;
import com.iflytek.skillhub.dto.AccountMergeSecondaryLocalAuthenticationRequest;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.MergeInitiateRequest;
@ -9,28 +21,308 @@ import com.iflytek.skillhub.dto.MergeInitiateResponse;
import com.iflytek.skillhub.dto.MergeVerifyRequest;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.service.AccountMergeAppService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Compatibility endpoints for the temporarily isolated legacy account merge flow.
* Safe account-merge resources plus fail-closed compatibility endpoints.
*
* <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.
* <p>The legacy initiate/verify/confirm flow returned the secondary-account verification token to
* the primary-account session and therefore did not prove independent control of both accounts.
* Those three routes remain stable but always fail closed; only the fresh-reauthentication and
* intent resources can perform a merge when the release gate is enabled.
*/
@RestController
@RequestMapping("/api/v1/account/merge")
public class AccountMergeController extends BaseApiController {
public AccountMergeController(ApiResponseFactory responseFactory) {
private final AccountMergeAppService accountMergeAppService;
public AccountMergeController(
ApiResponseFactory responseFactory,
AccountMergeAppService accountMergeAppService) {
super(responseFactory);
this.accountMergeAppService = accountMergeAppService;
}
@Operation(
summary =
"List safe account-merge authentication methods")
@AccountMergeMutationResponses
@GetMapping("/capabilities")
public ApiResponse<AccountMergeCapabilitiesResponse>
capabilities(HttpServletRequest request) {
return ok(
"response.success",
accountMergeAppService.capabilities(
requireSession(request)));
}
@Operation(
summary =
"Freshly reauthenticate the primary account"
+ " with its local password")
@AccountMergeMutationResponses
@PostMapping("/reauthenticate/local")
@RateLimit(
category = "account-merge-primary-local-reauth",
authenticated = 5,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<AccountMergePrimaryProofResponse>
reauthenticatePrimaryLocal(
@Valid @RequestBody
AccountMergeLocalReauthenticationRequest
request,
HttpServletRequest servletRequest) {
return ok(
"response.success.updated",
accountMergeAppService
.reauthenticatePrimaryLocal(
request.password(),
requireSession(servletRequest)));
}
@Operation(
summary =
"Start primary fresh authentication"
+ " through a browser provider")
@AccountMergeMutationResponses
@PostMapping("/reauthenticate/browser")
@RateLimit(
category = "account-merge-primary-browser-reauth",
authenticated = 5,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<AccountMergeBrowserStartResponse>
reauthenticatePrimaryBrowser(
@Valid @RequestBody
AccountMergeBrowserAuthenticationRequest
body,
HttpServletRequest request) {
return ok(
"response.success.updated",
accountMergeAppService
.reauthenticatePrimaryBrowser(
body.providerCode(),
requireSession(request)));
}
@Operation(
summary =
"Freshly authenticate the primary account"
+ " through a credential provider")
@AccountMergeMutationResponses
@PostMapping("/reauthenticate/credential")
@RateLimit(
category = "account-merge-primary-credential-reauth",
authenticated = 5,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<AccountMergePrimaryProofResponse>
reauthenticatePrimaryCredential(
@Valid @RequestBody
AccountMergeCredentialAuthenticationRequest
body,
HttpServletRequest request) {
return ok(
"response.success.updated",
accountMergeAppService
.reauthenticatePrimaryCredential(
body.providerCode(),
body.username(),
body.password(),
requireSession(request),
context(request)));
}
@Operation(summary = "Create a safe account-merge intent")
@AccountMergeMutationResponses
@PostMapping("/intents")
@RateLimit(
category = "account-merge-intent-create",
authenticated = 5,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<AccountMergeIntentResponse> createIntent(
HttpServletRequest request) {
return ok(
"response.success.created",
accountMergeAppService.createIntent(
requireSession(request),
context(request)));
}
@Operation(
summary =
"Prove control of the secondary local account")
@AccountMergeMutationResponses
@PostMapping(
"/intents/{intentId}/secondary-auth/local")
@RateLimit(
category = "account-merge-secondary-local-auth",
authenticated = 5,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<AccountMergeIntentResponse>
authenticateSecondaryLocal(
@PathVariable UUID intentId,
@Valid @RequestBody
AccountMergeSecondaryLocalAuthenticationRequest
body,
HttpServletRequest request) {
return ok(
"response.success.updated",
accountMergeAppService
.authenticateSecondaryLocal(
intentId,
body.username(),
body.password(),
requireSession(request),
context(request)));
}
@Operation(
summary =
"Start independent secondary authentication"
+ " through a browser provider")
@AccountMergeMutationResponses
@PostMapping(
"/intents/{intentId}/secondary-auth/browser")
@RateLimit(
category = "account-merge-secondary-browser-auth",
authenticated = 5,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<AccountMergeBrowserStartResponse>
prepareSecondaryBrowser(
@PathVariable UUID intentId,
@Valid @RequestBody
AccountMergeBrowserAuthenticationRequest
body,
HttpServletRequest request) {
return ok(
"response.success.updated",
accountMergeAppService
.prepareSecondaryBrowser(
intentId,
body.providerCode(),
requireSession(request),
context(request)));
}
@Operation(
summary =
"Independently authenticate the secondary"
+ " through a credential provider")
@AccountMergeMutationResponses
@PostMapping(
"/intents/{intentId}/secondary-auth/credential")
@RateLimit(
category = "account-merge-secondary-credential-auth",
authenticated = 5,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<AccountMergeIntentResponse>
authenticateSecondaryCredential(
@PathVariable UUID intentId,
@Valid @RequestBody
AccountMergeCredentialAuthenticationRequest
body,
HttpServletRequest request) {
return ok(
"response.success.updated",
accountMergeAppService
.authenticateSecondaryCredential(
intentId,
body.providerCode(),
body.username(),
body.password(),
requireSession(request),
context(request)));
}
@Operation(summary = "Read the current safe account-merge intent")
@AccountMergeMutationResponses
@GetMapping("/intents/{intentId}")
public ApiResponse<AccountMergeIntentResponse> getIntent(
@PathVariable UUID intentId,
HttpServletRequest request) {
return ok(
"response.success",
accountMergeAppService.getIntent(
intentId,
requireSession(request),
context(request)));
}
@Operation(summary = "Build a versioned account-merge preview")
@AccountMergeMutationResponses
@PostMapping("/intents/{intentId}/preview")
@RateLimit(
category = "account-merge-preview",
authenticated = 10,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<AccountMergePreviewResponse> preview(
@PathVariable UUID intentId,
HttpServletRequest request) {
return ok(
"response.success.updated",
accountMergeAppService.preview(
intentId,
requireSession(request),
context(request)));
}
@Operation(summary = "Confirm an unchanged account-merge preview")
@AccountMergeMutationResponses
@PostMapping("/intents/{intentId}/confirm")
@RateLimit(
category = "account-merge-confirm",
authenticated = 5,
anonymous = 1,
windowSeconds = 300)
public ApiResponse<AccountMergeCompletionResponse> confirm(
@PathVariable UUID intentId,
@Valid @RequestBody AccountMergeConfirmRequest body,
HttpServletRequest request) {
return ok(
"response.success.updated",
accountMergeAppService.confirm(
intentId,
body.previewVersion(),
requireSession(request),
context(request)));
}
@Operation(summary = "Cancel an active account-merge intent")
@AccountMergeMutationResponses
@DeleteMapping("/intents/{intentId}")
public ApiResponse<AccountMergeIntentResponse> cancel(
@PathVariable UUID intentId,
HttpServletRequest request) {
return ok(
"response.success.updated",
accountMergeAppService.cancel(
intentId,
requireSession(request),
context(request)));
}
@PostMapping("/initiate")
@ -68,4 +360,35 @@ public class AccountMergeController extends BaseApiController {
"error.auth.merge.temporarilyUnavailable"
);
}
private HttpSession requireSession(
HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null
|| !(session.getAttribute("platformPrincipal")
instanceof PlatformPrincipal)) {
throw new UnauthorizedException(
"error.auth.required");
}
return session;
}
private IdentityLoginContext context(
HttpServletRequest request) {
return new IdentityLoginContext(
bounded(MDC.get("requestId"), 64),
bounded(request.getRemoteAddr(), 64),
bounded(
request.getHeader("User-Agent"),
512));
}
private String bounded(
String value,
int maximumLength) {
return value == null
|| value.length() > maximumLength
? null
: value;
}
}

View file

@ -0,0 +1,72 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.dto.AccountMergeErrorResponse;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Stable error contract shared by the safe account-merge resources.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "Operation completed"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "Invalid account merge request",
content = @Content(
schema = @Schema(
implementation =
AccountMergeErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401",
description = "Fresh authentication failed or is required",
content = @Content(
schema = @Schema(
implementation =
AccountMergeErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403",
description = "Intent belongs to another browser session",
content = @Content(
schema = @Schema(
implementation =
AccountMergeErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "Account merge intent was not found",
content = @Content(
schema = @Schema(
implementation =
AccountMergeErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "409",
description = "Conflict, stale preview, or consumed intent",
content = @Content(
schema = @Schema(
implementation =
AccountMergeErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "410",
description = "Account merge proof or intent expired",
content = @Content(
schema = @Schema(
implementation =
AccountMergeErrorResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "503",
description = "Account merge or provider unavailable",
content = @Content(
schema = @Schema(
implementation =
AccountMergeErrorResponse.class)))
})
public @interface AccountMergeMutationResponses {
}

View file

@ -0,0 +1,35 @@
package com.iflytek.skillhub.dto;
import java.util.Objects;
/**
* Presentation-safe fresh-authentication capability.
*/
public record AccountMergeAuthenticationMethodResponse(
String providerCode,
String displayName,
String methodType
) {
public AccountMergeAuthenticationMethodResponse {
providerCode = requireText(
providerCode,
"providerCode");
displayName = requireText(
displayName,
"displayName");
methodType = requireText(
methodType,
"methodType");
}
private static String requireText(
String value,
String fieldName) {
Objects.requireNonNull(value, fieldName);
if (value.isBlank()) {
throw new IllegalArgumentException(
fieldName + " is required");
}
return value;
}
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record AccountMergeBrowserAuthenticationRequest(
@NotBlank
@Size(max = 64)
String providerCode
) {
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.dto;
public record AccountMergeBrowserStartResponse(
String actionUrl
) {
public AccountMergeBrowserStartResponse {
if (actionUrl == null
|| actionUrl.isBlank()
|| !actionUrl.startsWith("/")) {
throw new IllegalArgumentException(
"Invalid account merge action URL");
}
}
}

View file

@ -0,0 +1,18 @@
package com.iflytek.skillhub.dto;
import java.util.List;
/**
* Login methods that can independently prove the primary and secondary
* accounts without identifying a secondary account in advance.
*/
public record AccountMergeCapabilitiesResponse(
boolean enabled,
List<AccountMergeAuthenticationMethodResponse> primaryMethods,
List<AccountMergeAuthenticationMethodResponse> secondaryMethods
) {
public AccountMergeCapabilitiesResponse {
primaryMethods = List.copyOf(primaryMethods);
secondaryMethods = List.copyOf(secondaryMethods);
}
}

View file

@ -0,0 +1,12 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.auth.merge.AccountMergeIntentStatus;
import java.time.Instant;
import java.util.UUID;
public record AccountMergeCompletionResponse(
UUID intentId,
AccountMergeIntentStatus status,
Instant completedAt
) {
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Min;
public record AccountMergeConfirmRequest(
@Min(1) int previewVersion
) {
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record AccountMergeCredentialAuthenticationRequest(
@NotBlank
@Size(max = 64)
String providerCode,
@NotBlank
@Size(max = 320)
String username,
@NotBlank
@Size(max = 1024)
String password
) {
}

View file

@ -0,0 +1,31 @@
package com.iflytek.skillhub.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
/**
* Stable machine-readable error envelope for safe account merge.
*/
public record AccountMergeErrorResponse(
int code,
String msg,
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {
"ACCOUNT_MERGE_UNAVAILABLE",
"MERGE_INTENT_NOT_FOUND",
"MERGE_REAUTH_REQUIRED",
"MERGE_PROVIDER_AUTHENTICATION_FAILED",
"MERGE_PROVIDER_UNAVAILABLE",
"MERGE_SESSION_MISMATCH",
"MERGE_PROOF_EXPIRED",
"MERGE_CONFLICT",
"MERGE_PREVIEW_STALE",
"MERGE_ALREADY_CONSUMED",
"MERGE_ACCOUNT_NOT_ELIGIBLE"
})
String reasonCode,
Instant timestamp,
String requestId
) {
}

View file

@ -0,0 +1,18 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.auth.merge.AccountMergeIntentStatus;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record AccountMergeIntentResponse(
UUID id,
AccountMergeIntentStatus status,
Instant expiresAt,
List<AccountMergeAuthenticationMethodResponse>
secondaryMethods
) {
public AccountMergeIntentResponse {
secondaryMethods = List.copyOf(secondaryMethods);
}
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record AccountMergeLocalReauthenticationRequest(
@NotBlank(
message =
"{validation.auth.accountMerge.password.notBlank}")
String password
) {
}

View file

@ -0,0 +1,74 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.auth.merge.AccountMergeIntentStatus;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/**
* Credential-free account merge preview.
*/
public record AccountMergePreviewResponse(
UUID intentId,
AccountMergeIntentStatus status,
int previewVersion,
Instant expiresAt,
boolean confirmable,
List<String> identityProviders,
String localCredentialAction,
List<String> blockedPlatformRoles,
List<NamespaceChange> namespaceChanges,
List<ApiToken> apiTokensToRevoke,
int skillOwnershipCount,
SocialSummary social,
NotificationSummary notifications,
List<Conflict> conflicts
) {
public record NamespaceChange(
long namespaceId,
String namespaceSlug,
String primaryRole,
String secondaryRole,
String resultingRole,
boolean blocked
) {
}
public record ApiToken(
String name,
String prefix
) {
}
public record SocialSummary(
int starsMoved,
int duplicateStarsDiscarded,
int ratingsMoved,
int duplicateRatingsDiscarded,
int subscriptionsMoved,
int duplicateSubscriptionsDiscarded,
List<DiscardedRating> discardedRatings
) {
}
public record DiscardedRating(
long skillId,
int score
) {
}
public record NotificationSummary(
int notificationsMoved,
int preferencesMoved,
int duplicatePreferencesDiscarded,
int governanceNotificationsMoved
) {
}
public record Conflict(
String code,
String resource,
String suggestedAction
) {
}
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
public record AccountMergePrimaryProofResponse(
String method,
Instant expiresAt
) {
}

View file

@ -0,0 +1,20 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record AccountMergeSecondaryLocalAuthenticationRequest(
@NotBlank(
message =
"{validation.auth.accountMerge.username.notBlank}")
@Size(
max = 64,
message =
"{validation.auth.accountMerge.username.size}")
String username,
@NotBlank(
message =
"{validation.auth.accountMerge.password.notBlank}")
String password
) {
}

View file

@ -66,4 +66,34 @@ public class ApiResponseFactory {
Instant.now(clock),
requestIdAccessor.current());
}
public AccountMergeErrorResponse accountMergeError(
int code,
String messageCode,
String reasonCode,
Object... args) {
String msg = messageSource.getMessage(
messageCode,
args,
messageCode,
LocaleContextHolder.getLocale());
return new AccountMergeErrorResponse(
code,
msg,
reasonCode,
Instant.now(clock),
requestIdAccessor.current());
}
public AccountMergeErrorResponse accountMergeErrorMessage(
int code,
String message,
String reasonCode) {
return new AccountMergeErrorResponse(
code,
message,
reasonCode,
Instant.now(clock),
requestIdAccessor.current());
}
}

View file

@ -1,12 +1,16 @@
package com.iflytek.skillhub.exception;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.config.AccountMergeRouteRequestMatcher;
import com.iflytek.skillhub.auth.config.IdentityLinkRouteRequestMatcher;
import com.iflytek.skillhub.auth.merge.AccountMergeException;
import com.iflytek.skillhub.auth.merge.AccountMergeFailureCode;
import com.iflytek.skillhub.auth.identity.IdentityLinkException;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.AccountMergeErrorResponse;
import com.iflytek.skillhub.dto.IdentityLinkErrorResponse;
import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException;
import com.iflytek.skillhub.domain.shared.exception.LocalizedMessage;
@ -110,6 +114,23 @@ public class GlobalExceptionHandler {
ex.messageArgs()));
}
@ExceptionHandler(AccountMergeException.class)
public ResponseEntity<AccountMergeErrorResponse>
handleAccountMergeException(
AccountMergeException ex,
HttpServletRequest request) {
logHandledException(
ex.getStatus(),
ex.messageCode(),
request);
return ResponseEntity.status(ex.getStatus()).body(
apiResponseFactory.accountMergeError(
ex.getStatus().value(),
ex.messageCode(),
ex.getReasonCode().name(),
ex.messageArgs()));
}
@ExceptionHandler(LocalizedDomainException.class)
public ResponseEntity<ApiResponse<Void>> handleLocalizedDomainException(LocalizedDomainException ex, HttpServletRequest request) {
return renderLocalizedError(ex, HttpStatus.valueOf(ex.statusCode()), request);
@ -139,6 +160,24 @@ public class GlobalExceptionHandler {
.INVALID_OPERATION
.name()));
}
if (AccountMergeRouteRequestMatcher.matches(request)) {
if (msg == null || msg.isBlank()) {
return ResponseEntity.badRequest().body(
apiResponseFactory.accountMergeError(
400,
"error.auth.accountMerge.invalidOperation",
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED
.name()));
}
return ResponseEntity.badRequest().body(
apiResponseFactory.accountMergeErrorMessage(
400,
msg,
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED
.name()));
}
if (msg == null || msg.isBlank()) {
return ResponseEntity.badRequest().body(apiResponseFactory.error(400, "error.badRequest"));
}
@ -161,6 +200,17 @@ public class GlobalExceptionHandler {
.INVALID_OPERATION
.name()));
}
if (AccountMergeRouteRequestMatcher.matches(request)) {
return ResponseEntity.badRequest().body(
apiResponseFactory.accountMergeError(
400,
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED
.messageCode(),
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED
.name()));
}
return ResponseEntity.badRequest().body(
apiResponseFactory.error(400, "error.badRequest"));
}

View file

@ -0,0 +1,142 @@
package com.iflytek.skillhub.repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Optional;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
/**
* PostgreSQL lease queue for reliable secondary-session revocation.
*
* <p>Direct SQL is required because claiming work must combine
* {@code FOR UPDATE SKIP LOCKED}, lease recovery, and an atomic state update.
* This is a command repository for an app-owned operational queue, not a
* presentation query repository.
*/
@Repository
public class AccountMergeSessionRevocationRepository {
private final JdbcTemplate jdbcTemplate;
public AccountMergeSessionRevocationRepository(
JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public Optional<Claim> claimNext(
Instant now,
Duration leaseDuration) {
Instant leaseUntil = now.plus(leaseDuration);
return jdbcTemplate.query(
"""
WITH due AS (
SELECT id
FROM account_merge_session_revocation
WHERE (
status = 'PENDING'
AND next_attempt_at <= ?
) OR (
status = 'PROCESSING'
AND lease_until <= ?
)
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE account_merge_session_revocation task
SET status = 'PROCESSING',
attempt_count = task.attempt_count + 1,
lease_until = ?,
updated_at = ?
FROM due
WHERE task.id = due.id
RETURNING task.id,
task.user_id,
task.attempt_count,
task.lease_until
""",
resultSet -> resultSet.next()
? Optional.of(mapClaim(resultSet))
: Optional.empty(),
atOffset(now),
atOffset(now),
atOffset(leaseUntil),
atOffset(now));
}
public boolean complete(
Claim claim,
Instant completedAt) {
return jdbcTemplate.update(
"""
UPDATE account_merge_session_revocation
SET status = 'COMPLETED',
lease_until = NULL,
completed_at = ?,
last_error_code = NULL,
updated_at = ?
WHERE id = ?
AND status = 'PROCESSING'
AND attempt_count = ?
""",
atOffset(completedAt),
atOffset(completedAt),
claim.id(),
claim.attemptCount()) == 1;
}
public boolean retry(
Claim claim,
Instant nextAttemptAt,
String errorCode,
Instant updatedAt) {
return jdbcTemplate.update(
"""
UPDATE account_merge_session_revocation
SET status = 'PENDING',
lease_until = NULL,
next_attempt_at = ?,
last_error_code = ?,
updated_at = ?
WHERE id = ?
AND status = 'PROCESSING'
AND attempt_count = ?
""",
atOffset(nextAttemptAt),
errorCode,
atOffset(updatedAt),
claim.id(),
claim.attemptCount()) == 1;
}
private Claim mapClaim(ResultSet resultSet)
throws SQLException {
return new Claim(
resultSet.getLong("id"),
resultSet.getString("user_id"),
resultSet.getInt("attempt_count"),
resultSet.getObject(
"lease_until",
OffsetDateTime.class)
.toInstant());
}
private OffsetDateTime atOffset(Instant instant) {
return instant.atOffset(ZoneOffset.UTC);
}
public record Claim(
long id,
String userId,
int attemptCount,
Instant leaseUntil
) {
}
}

View file

@ -0,0 +1,686 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
import com.iflytek.skillhub.auth.identity.IdentityLinkAccountState;
import com.iflytek.skillhub.auth.identity.IdentityLinkBindingView;
import com.iflytek.skillhub.auth.identity.IdentityLinkIntentService;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod;
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.merge.AccountMergeActor;
import com.iflytek.skillhub.auth.merge.AccountMergeCompletion;
import com.iflytek.skillhub.auth.merge.AccountMergeException;
import com.iflytek.skillhub.auth.merge.AccountMergeFailureCode;
import com.iflytek.skillhub.auth.merge.AccountMergeIntent;
import com.iflytek.skillhub.auth.merge.AccountMergeIntentService;
import com.iflytek.skillhub.auth.merge.AccountMergeMetrics;
import com.iflytek.skillhub.auth.merge.AccountMergePlan;
import com.iflytek.skillhub.auth.merge.AccountMergePrimaryProof;
import com.iflytek.skillhub.auth.merge.AccountMergePreview;
import com.iflytek.skillhub.auth.merge.AccountMergeProviderPrimaryProof;
import com.iflytek.skillhub.auth.merge.AccountMergeProviderProofService;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest;
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.AccountMergeAuthenticationMethodResponse;
import com.iflytek.skillhub.dto.AccountMergeBrowserStartResponse;
import com.iflytek.skillhub.dto.AccountMergeCapabilitiesResponse;
import com.iflytek.skillhub.dto.AccountMergeCompletionResponse;
import com.iflytek.skillhub.dto.AccountMergeIntentResponse;
import com.iflytek.skillhub.dto.AccountMergePrimaryProofResponse;
import com.iflytek.skillhub.dto.AccountMergePreviewResponse;
import jakarta.servlet.http.HttpSession;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.web.util.UriComponentsBuilder;
/**
* HTTP-session orchestration for safe account-merge intent creation.
*/
@Service
public class AccountMergeAppService {
private final AccountMergeIntentService intentService;
private final LocalAuthService localAuthService;
private final AccountMergeSessionManager sessionManager;
private final AccountMergeProviderProofService
providerProofService;
private final IdentityProviderRegistry providerRegistry;
private final IdentityLinkIntentService
identityLinkIntentService;
private final IdentityLinkSessionManager
identityLinkSessionManager;
private final AccountMergeMetrics metrics;
private final MessageSource messageSource;
public AccountMergeAppService(
AccountMergeIntentService intentService,
LocalAuthService localAuthService,
AccountMergeSessionManager sessionManager,
AccountMergeProviderProofService providerProofService,
IdentityProviderRegistry providerRegistry,
IdentityLinkIntentService identityLinkIntentService,
IdentityLinkSessionManager
identityLinkSessionManager,
AccountMergeMetrics metrics,
MessageSource messageSource) {
this.intentService = intentService;
this.localAuthService = localAuthService;
this.sessionManager = sessionManager;
this.providerProofService = providerProofService;
this.providerRegistry = providerRegistry;
this.identityLinkIntentService =
identityLinkIntentService;
this.identityLinkSessionManager =
identityLinkSessionManager;
this.metrics = metrics;
this.messageSource = messageSource;
}
public AccountMergeCapabilitiesResponse capabilities(
HttpSession session) {
PlatformPrincipal principal = requirePrincipal(session);
if (!intentService.isAvailable()) {
return new AccountMergeCapabilitiesResponse(
false,
List.of(),
List.of());
}
return new AccountMergeCapabilitiesResponse(
true,
primaryMethods(principal.userId()),
secondaryMethods());
}
public AccountMergePrimaryProofResponse
reauthenticatePrimaryLocal(
String password,
HttpSession session) {
intentService.requireAvailable();
PlatformPrincipal principal =
requirePrincipal(session);
try {
localAuthService.reauthenticate(
principal.userId(),
password);
} catch (AuthFlowException exception) {
metrics.record(
"proof",
"primary_local_failure");
throw new AccountMergeException(
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED,
exception);
}
metrics.record(
"proof",
"primary_local_success");
AccountMergePrimaryProof proof =
sessionManager.recordPrimaryReauthentication(
session,
principal.userId(),
"local-password");
return new AccountMergePrimaryProofResponse(
proof.method(),
proof.expiresAt());
}
public AccountMergeBrowserStartResponse
reauthenticatePrimaryBrowser(
String providerCode,
HttpSession session) {
intentService.requireAvailable();
PlatformPrincipal principal =
requirePrincipal(session);
IdentityProviderLoginMethodType methodType =
requirePrimaryProviderMethod(
principal.userId(),
providerCode,
Set.of(
IdentityProviderLoginMethodType
.OAUTH_REDIRECT,
IdentityProviderLoginMethodType
.CAS_REDIRECT));
identityLinkSessionManager.clearBrowserFlow(session);
sessionManager.preparePrimaryBrowserFlow(
session,
providerCode);
return new AccountMergeBrowserStartResponse(
browserAuthorizationUrl(
providerCode,
methodType,
"/settings/accounts"
+ "?accountMerge=primaryProved"));
}
public AccountMergePrimaryProofResponse
reauthenticatePrimaryCredential(
String providerCode,
String username,
String password,
HttpSession session,
IdentityLoginContext context) {
intentService.requireAvailable();
PlatformPrincipal principal =
requirePrincipal(session);
requirePrimaryProviderMethod(
principal.userId(),
providerCode,
Set.of(
IdentityProviderLoginMethodType
.DIRECT_PASSWORD));
IdentityProviderRegistry.CredentialRoute route =
requireCredentialRoute(providerCode);
AccountMergeProviderPrimaryProof result =
providerProofService.completePrimary(
session,
route.provider(),
authenticate(
route,
username,
password),
context);
return new AccountMergePrimaryProofResponse(
result.proof().method(),
result.proof().expiresAt());
}
public AccountMergeIntentResponse createIntent(
HttpSession session,
IdentityLoginContext context) {
intentService.requireAvailable();
UUID intentId = UUID.randomUUID();
AccountMergeActor actor;
try {
actor = sessionManager.startIntent(
session,
intentId,
context);
} catch (AccountMergeException exception) {
metrics.record(
"proof",
exception.getReasonCode()
== AccountMergeFailureCode
.MERGE_PROOF_EXPIRED
? "expired"
: "primary_failure");
throw exception;
}
try {
return toResponse(
intentService.createIntent(
actor,
intentId));
} catch (RuntimeException exception) {
sessionManager.remove(session, intentId);
throw exception;
}
}
public AccountMergeIntentResponse
authenticateSecondaryLocal(
UUID intentId,
String username,
String password,
HttpSession session,
IdentityLoginContext context) {
intentService.requireAvailable();
AccountMergeActor actor = sessionManager.actor(
session,
intentId,
context);
intentService.getIntent(actor, intentId);
PlatformPrincipal secondary;
try {
secondary = localAuthService.login(
username,
password);
} catch (AuthFlowException exception) {
metrics.record(
"proof",
"secondary_local_failure");
throw new AccountMergeException(
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED,
exception);
}
metrics.record(
"proof",
"secondary_local_success");
return toResponse(
intentService.recordSecondaryProof(
actor,
intentId,
secondary.userId(),
"local-password"));
}
public AccountMergeBrowserStartResponse
prepareSecondaryBrowser(
UUID intentId,
String providerCode,
HttpSession session,
IdentityLoginContext context) {
intentService.requireAvailable();
AccountMergeActor actor = sessionManager.actor(
session,
intentId,
context);
intentService.getIntent(actor, intentId);
IdentityProviderLoginMethodType methodType =
requireReadyBrowserMethod(providerCode);
identityLinkSessionManager.clearBrowserFlow(session);
sessionManager.prepareSecondaryBrowserFlow(
session,
intentId,
providerCode,
context);
return new AccountMergeBrowserStartResponse(
browserAuthorizationUrl(
providerCode,
methodType,
"/settings/accounts"
+ "?accountMerge=secondaryProved"
+ "&intentId="
+ intentId));
}
public AccountMergeIntentResponse
authenticateSecondaryCredential(
UUID intentId,
String providerCode,
String username,
String password,
HttpSession session,
IdentityLoginContext context) {
intentService.requireAvailable();
AccountMergeActor actor = sessionManager.actor(
session,
intentId,
context);
intentService.getIntent(actor, intentId);
IdentityProviderRegistry.CredentialRoute route =
requireCredentialRoute(providerCode);
return toResponse(
providerProofService.completeSecondary(
actor,
intentId,
route.provider(),
authenticate(
route,
username,
password),
context));
}
public AccountMergeIntentResponse getIntent(
UUID intentId,
HttpSession session,
IdentityLoginContext context) {
return toResponse(intentService.getIntent(
sessionManager.actor(
session,
intentId,
context),
intentId));
}
public AccountMergePreviewResponse preview(
UUID intentId,
HttpSession session,
IdentityLoginContext context) {
AccountMergePreview preview = intentService.preview(
sessionManager.actor(
session,
intentId,
context),
intentId);
return toResponse(preview);
}
public AccountMergeCompletionResponse confirm(
UUID intentId,
int previewVersion,
HttpSession session,
IdentityLoginContext context) {
AccountMergeCompletion completion =
intentService.confirm(
sessionManager.actor(
session,
intentId,
context),
intentId,
previewVersion);
sessionManager.remove(session, intentId);
return new AccountMergeCompletionResponse(
completion.intentId(),
completion.status(),
completion.completedAt());
}
public AccountMergeIntentResponse cancel(
UUID intentId,
HttpSession session,
IdentityLoginContext context) {
AccountMergeIntent intent = intentService.cancel(
sessionManager.actor(
session,
intentId,
context),
intentId);
sessionManager.remove(session, intentId);
return toResponse(intent);
}
private PlatformPrincipal requirePrincipal(
HttpSession session) {
Object value = session == null
? null
: session.getAttribute("platformPrincipal");
if (!(value instanceof PlatformPrincipal principal)) {
throw new AccountMergeException(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH);
}
return principal;
}
private AccountMergeIntentResponse toResponse(
AccountMergeIntent intent) {
return new AccountMergeIntentResponse(
intent.id(),
intent.status(),
intent.expiresAt(),
secondaryMethods());
}
private AccountMergePreviewResponse toResponse(
AccountMergePreview preview) {
AccountMergePlan plan = preview.plan();
return new AccountMergePreviewResponse(
preview.intentId(),
preview.status(),
preview.previewVersion(),
preview.expiresAt(),
plan.confirmable(),
plan.identityProviders(),
plan.localCredentialAction().name(),
plan.blockedPlatformRoles(),
plan.namespaceChanges().stream()
.map(change ->
new AccountMergePreviewResponse
.NamespaceChange(
change.namespaceId(),
change.namespaceSlug(),
change.primaryRole(),
change.secondaryRole(),
change.resultingRole(),
change.blocked()))
.toList(),
plan.apiTokensToRevoke().stream()
.map(token ->
new AccountMergePreviewResponse
.ApiToken(
token.name(),
token.prefix()))
.toList(),
plan.skillOwnershipCount(),
new AccountMergePreviewResponse.SocialSummary(
plan.social().starsMoved(),
plan.social()
.duplicateStarsDiscarded(),
plan.social().ratingsMoved(),
plan.social()
.duplicateRatingsDiscarded(),
plan.social().subscriptionsMoved(),
plan.social()
.duplicateSubscriptionsDiscarded(),
plan.social().discardedRatings().stream()
.map(rating ->
new AccountMergePreviewResponse
.DiscardedRating(
rating.skillId(),
rating.score()))
.toList()),
new AccountMergePreviewResponse
.NotificationSummary(
plan.notifications()
.notificationsMoved(),
plan.notifications()
.preferencesMoved(),
plan.notifications()
.duplicatePreferencesDiscarded(),
plan.notifications()
.governanceNotificationsMoved()),
plan.conflicts().stream()
.map(conflict ->
new AccountMergePreviewResponse
.Conflict(
conflict.code().name(),
conflict.resource(),
conflict.suggestedAction()
.name()))
.toList());
}
private List<AccountMergeAuthenticationMethodResponse>
primaryMethods(String userId) {
IdentityLinkAccountState state =
identityLinkIntentService.accountState(userId);
List<AccountMergeAuthenticationMethodResponse> methods =
new ArrayList<>();
if (state.localPasswordEnabled()) {
methods.add(localPasswordMethod());
}
for (IdentityLinkBindingView binding
: state.linkedProviders()) {
if (!binding.usable()) {
continue;
}
for (IdentityProviderLoginMethodType methodType
: binding.methodTypes()) {
if (isFreshAuthenticationMethod(methodType)) {
methods.add(method(
binding.providerCode(),
binding.displayName(),
methodType));
}
}
}
return sortedDistinct(methods);
}
private List<AccountMergeAuthenticationMethodResponse>
secondaryMethods() {
List<AccountMergeAuthenticationMethodResponse> methods =
new ArrayList<>();
methods.add(localPasswordMethod());
for (IdentityProviderLoginMethod method
: providerRegistry.listReadyLoginMethods()) {
if (isFreshAuthenticationMethod(
method.methodType())) {
methods.add(method(
method.providerCode(),
method.displayName(),
method.methodType()));
}
}
return sortedDistinct(methods);
}
private List<AccountMergeAuthenticationMethodResponse>
sortedDistinct(
List<AccountMergeAuthenticationMethodResponse>
methods) {
Map<String, AccountMergeAuthenticationMethodResponse>
distinct = new LinkedHashMap<>();
methods.stream()
.sorted(Comparator
.comparing(
AccountMergeAuthenticationMethodResponse
::providerCode)
.thenComparing(
AccountMergeAuthenticationMethodResponse
::methodType))
.forEach(method -> distinct.putIfAbsent(
method.providerCode()
+ ":"
+ method.methodType(),
method));
return List.copyOf(distinct.values());
}
private AccountMergeAuthenticationMethodResponse
localPasswordMethod() {
return new AccountMergeAuthenticationMethodResponse(
"local",
messageSource.getMessage(
"auth.accountMerge.method.localPassword",
null,
"Local password",
LocaleContextHolder.getLocale()),
"LOCAL_PASSWORD");
}
private AccountMergeAuthenticationMethodResponse method(
String providerCode,
String displayName,
IdentityProviderLoginMethodType methodType) {
return new AccountMergeAuthenticationMethodResponse(
providerCode,
displayName,
methodType.name());
}
private boolean isFreshAuthenticationMethod(
IdentityProviderLoginMethodType methodType) {
return methodType
!= IdentityProviderLoginMethodType
.SESSION_BOOTSTRAP;
}
private IdentityProviderLoginMethodType
requirePrimaryProviderMethod(
String userId,
String providerCode,
Set<IdentityProviderLoginMethodType>
allowedTypes) {
return identityLinkIntentService.accountState(userId)
.linkedProviders()
.stream()
.filter(IdentityLinkBindingView::usable)
.filter(binding ->
binding.providerCode().equals(
providerCode))
.flatMap(binding ->
binding.methodTypes().stream())
.filter(allowedTypes::contains)
.sorted()
.findFirst()
.orElseThrow(() ->
new AccountMergeException(
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE));
}
private IdentityProviderLoginMethodType
requireReadyBrowserMethod(String providerCode) {
boolean casAvailable = false;
for (IdentityProviderLoginMethod method
: providerRegistry.listReadyLoginMethods()) {
if (!method.providerCode().equals(providerCode)) {
continue;
}
if (method.methodType()
== IdentityProviderLoginMethodType
.OAUTH_REDIRECT) {
return method.methodType();
}
if (method.methodType()
== IdentityProviderLoginMethodType
.CAS_REDIRECT) {
casAvailable = true;
}
}
if (casAvailable) {
return IdentityProviderLoginMethodType.CAS_REDIRECT;
}
throw new AccountMergeException(
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE);
}
private IdentityProviderRegistry.CredentialRoute
requireCredentialRoute(String providerCode) {
try {
return providerRegistry.requireCredentialRoute(
providerCode);
} catch (IdentityCoreException exception) {
throw new AccountMergeException(
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE,
exception);
}
}
private com.iflytek.skillhub.auth.identity
.ProviderAuthenticationResult authenticate(
IdentityProviderRegistry.CredentialRoute route,
String username,
String password) {
try {
return route.adapter().authenticate(
new CredentialAuthenticationRequest(
username,
password));
} catch (ProviderAuthenticationException exception) {
throw ProviderAuthenticationFailureMapper
.mapAccountMerge(exception);
}
}
private String browserAuthorizationUrl(
String providerCode,
IdentityProviderLoginMethodType methodType,
String returnTo) {
if (methodType
== IdentityProviderLoginMethodType
.OAUTH_REDIRECT) {
return "/oauth2/authorization/"
+ providerCode
+ "?returnTo="
+ URLEncoder.encode(
returnTo,
StandardCharsets.UTF_8);
}
if (methodType
== IdentityProviderLoginMethodType
.CAS_REDIRECT) {
return UriComponentsBuilder.fromPath(
"/api/v1/auth/cas/"
+ "{providerCode}/login")
.queryParam("returnTo", returnTo)
.buildAndExpand(providerCode)
.encode()
.toUriString();
}
throw new AccountMergeException(
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE);
}
}

View file

@ -16,6 +16,12 @@ import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.merge.AccountMergeBrowserFlow;
import com.iflytek.skillhub.auth.merge.AccountMergeBrowserFlowReference;
import com.iflytek.skillhub.auth.merge.AccountMergeException;
import com.iflytek.skillhub.auth.merge.AccountMergeFailureCode;
import com.iflytek.skillhub.auth.merge.AccountMergeProviderProofService;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
@ -56,6 +62,10 @@ public class CasLoginAppService {
private final ProviderLoginAppService providerLoginAppService;
private final ExternalIdentityLinkService externalIdentityLinkService;
private final IdentityLinkSessionManager identityLinkSessionManager;
private final AccountMergeProviderProofService
accountMergeProviderProofService;
private final AccountMergeSessionManager
accountMergeSessionManager;
private final PlatformSessionService platformSessionService;
private final CasLoginStateStore stateStore;
private final AuditLogService auditLogService;
@ -68,6 +78,10 @@ public class CasLoginAppService {
ProviderLoginAppService providerLoginAppService,
ExternalIdentityLinkService externalIdentityLinkService,
IdentityLinkSessionManager identityLinkSessionManager,
AccountMergeProviderProofService
accountMergeProviderProofService,
AccountMergeSessionManager
accountMergeSessionManager,
PlatformSessionService platformSessionService,
CasLoginStateStore stateStore,
AuditLogService auditLogService) {
@ -77,6 +91,8 @@ public class CasLoginAppService {
providerLoginAppService,
externalIdentityLinkService,
identityLinkSessionManager,
accountMergeProviderProofService,
accountMergeSessionManager,
platformSessionService,
stateStore,
auditLogService,
@ -89,6 +105,10 @@ public class CasLoginAppService {
ProviderLoginAppService providerLoginAppService,
ExternalIdentityLinkService externalIdentityLinkService,
IdentityLinkSessionManager identityLinkSessionManager,
AccountMergeProviderProofService
accountMergeProviderProofService,
AccountMergeSessionManager
accountMergeSessionManager,
PlatformSessionService platformSessionService,
CasLoginStateStore stateStore,
AuditLogService auditLogService,
@ -98,6 +118,10 @@ public class CasLoginAppService {
this.providerLoginAppService = providerLoginAppService;
this.externalIdentityLinkService = externalIdentityLinkService;
this.identityLinkSessionManager = identityLinkSessionManager;
this.accountMergeProviderProofService =
accountMergeProviderProofService;
this.accountMergeSessionManager =
accountMergeSessionManager;
this.platformSessionService = platformSessionService;
this.stateStore = stateStore;
this.auditLogService = auditLogService;
@ -108,8 +132,18 @@ public class CasLoginAppService {
String providerCode,
String returnTo,
HttpServletRequest request) {
requireRoute(providerCode);
HttpSession session = request.getSession(true);
try {
requireRoute(providerCode);
} catch (CasLoginFlowException exception) {
return consumePreparedFlowFailure(
session,
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE,
IdentityLinkFailureCode
.PROVIDER_UNAVAILABLE)
.orElseThrow(() -> exception);
}
String state = stateSupplier.get();
CasLoginInitiation initiation;
try {
@ -117,7 +151,18 @@ public class CasLoginAppService {
providerCode,
state);
} catch (ProviderAuthenticationException exception) {
throw failure(CasLoginFailure.PROVIDER_UNAVAILABLE);
return consumePreparedFlowFailure(
session,
ProviderAuthenticationFailureMapper
.mapAccountMerge(exception)
.getReasonCode(),
ProviderAuthenticationFailureMapper
.mapIdentityLink(exception)
.getReasonCode())
.orElseThrow(() ->
failure(
CasLoginFailure
.PROVIDER_UNAVAILABLE));
}
try {
@ -132,8 +177,20 @@ public class CasLoginAppService {
session,
providerCode,
state);
accountMergeSessionManager.activateBrowserFlow(
session,
providerCode,
state);
} catch (CasLoginStateStore.CasLoginStateStoreException exception) {
throw failure(CasLoginFailure.INTERNAL_ERROR);
return consumePreparedFlowFailure(
session,
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE,
IdentityLinkFailureCode
.PROVIDER_UNAVAILABLE)
.orElseThrow(() ->
failure(
CasLoginFailure.INTERNAL_ERROR));
}
return initiation.loginUri();
}
@ -146,6 +203,18 @@ public class CasLoginAppService {
CasLoginStateStore.CasLoginState loginState =
consumeState(providerCode, state, request);
IdentityLoginContext context = context(request);
Optional<AccountMergeBrowserFlow> accountMergeFlow;
try {
accountMergeFlow =
accountMergeSessionManager.consumeBrowserFlow(
request,
providerCode,
context);
} catch (AccountMergeException exception) {
return accountMergeFailureTarget(
loginState.returnTo(),
exception.getReasonCode());
}
Optional<IdentityLinkBrowserFlow> identityLinkFlow;
try {
identityLinkFlow =
@ -157,6 +226,12 @@ public class CasLoginAppService {
throw failure(CasLoginFailure.INVALID_STATE);
}
if (ticket == null || ticket.isBlank()) {
if (accountMergeFlow.isPresent()) {
return accountMergeFailureTarget(
accountMergeFlow.orElseThrow(),
AccountMergeFailureCode
.MERGE_PROVIDER_AUTHENTICATION_FAILED);
}
if (identityLinkFlow.isPresent()) {
return identityLinkFailureTarget(
identityLinkFlow.orElseThrow().intentId(),
@ -175,7 +250,14 @@ public class CasLoginAppService {
ticket,
loginState.serviceUrl());
var result = route.adapter().authenticate(exchange);
if (identityLinkFlow.isPresent()) {
if (accountMergeFlow.isPresent()) {
completeAccountMerge(
accountMergeFlow.orElseThrow(),
route,
result,
request,
context);
} else if (identityLinkFlow.isPresent()) {
completeIdentityLink(
identityLinkFlow.orElseThrow(),
route,
@ -200,6 +282,13 @@ public class CasLoginAppService {
request,
"ticket");
}
if (accountMergeFlow.isPresent()) {
return accountMergeFailureTarget(
accountMergeFlow.orElseThrow(),
ProviderAuthenticationFailureMapper
.mapAccountMerge(exception)
.getReasonCode());
}
if (identityLinkFlow.isPresent()) {
return identityLinkFailureTarget(
identityLinkFlow.orElseThrow().intentId(),
@ -208,6 +297,13 @@ public class CasLoginAppService {
.getReasonCode());
}
throw mapProviderFailure(exception);
} catch (AccountMergeException exception) {
if (accountMergeFlow.isPresent()) {
return accountMergeFailureTarget(
accountMergeFlow.orElseThrow(),
exception.getReasonCode());
}
throw failure(CasLoginFailure.INTERNAL_ERROR);
} catch (IdentityLinkException exception) {
if (identityLinkFlow.isPresent()) {
return identityLinkFailureTarget(
@ -216,6 +312,12 @@ public class CasLoginAppService {
}
throw failure(CasLoginFailure.INTERNAL_ERROR);
} catch (IdentityCoreException exception) {
if (accountMergeFlow.isPresent()) {
return accountMergeFailureTarget(
accountMergeFlow.orElseThrow(),
mapAccountMergeIdentityFailure(
exception));
}
if (identityLinkFlow.isPresent()) {
return identityLinkFailureTarget(
identityLinkFlow.orElseThrow().intentId(),
@ -317,6 +419,128 @@ public class CasLoginAppService {
"Unsupported CAS identity link outcome");
}
private void completeAccountMerge(
AccountMergeBrowserFlow flow,
IdentityProviderRegistry.BrowserRoute
<CasAuthenticationExchange> route,
ProviderAuthenticationResult result,
HttpServletRequest request,
IdentityLoginContext context) {
HttpSession session = request.getSession(false);
if (flow instanceof AccountMergeBrowserFlow.Primary) {
accountMergeProviderProofService.completePrimary(
session,
route.provider(),
result,
context);
return;
}
AccountMergeBrowserFlow.Secondary secondary =
(AccountMergeBrowserFlow.Secondary) flow;
accountMergeProviderProofService.completeSecondary(
secondary.actor(),
secondary.intentId(),
route.provider(),
result,
context);
}
private Optional<URI> consumePreparedFlowFailure(
HttpSession session,
AccountMergeFailureCode accountMergeReason,
IdentityLinkFailureCode identityLinkReason) {
Optional<AccountMergeBrowserFlowReference>
accountMergeFlow =
accountMergeSessionManager
.consumeFailedBrowserFlow(session);
if (accountMergeFlow.isPresent()) {
return Optional.of(URI.create(
accountMergeFailureTarget(
accountMergeFlow.orElseThrow(),
accountMergeReason)));
}
return identityLinkSessionManager
.consumeFailedBrowserFlow(session)
.map(intentId -> URI.create(
identityLinkFailureTarget(
intentId,
identityLinkReason)));
}
private String accountMergeFailureTarget(
AccountMergeBrowserFlow flow,
AccountMergeFailureCode reasonCode) {
UUID intentId =
flow instanceof AccountMergeBrowserFlow.Secondary
secondary
? secondary.intentId()
: null;
return accountMergeFailureTarget(
flow instanceof AccountMergeBrowserFlow.Primary
? "PRIMARY_REAUTHENTICATION"
: "SECONDARY_AUTHENTICATION",
intentId,
reasonCode);
}
private String accountMergeFailureTarget(
AccountMergeBrowserFlowReference flow,
AccountMergeFailureCode reasonCode) {
return accountMergeFailureTarget(
flow.phase().name(),
flow.intentId(),
reasonCode);
}
private String accountMergeFailureTarget(
String returnTo,
AccountMergeFailureCode reasonCode) {
return accountMergeFailureTarget(
"UNKNOWN",
intentIdFromReturnTo(returnTo).orElse(null),
reasonCode);
}
private String accountMergeFailureTarget(
String phase,
UUID intentId,
AccountMergeFailureCode reasonCode) {
return "/settings/accounts?accountMerge=failed"
+ "&phase="
+ phase
+ (intentId == null
? ""
: "&intentId=" + intentId)
+ "&reasonCode="
+ reasonCode.name();
}
private Optional<UUID> intentIdFromReturnTo(
String returnTo) {
if (returnTo == null
|| !returnTo.startsWith(
"/settings/accounts?")) {
return Optional.empty();
}
String query = returnTo.substring(
returnTo.indexOf('?') + 1);
for (String parameter : query.split("&")) {
int separator = parameter.indexOf('=');
if (separator <= 0
|| !"intentId".equals(
parameter.substring(0, separator))) {
continue;
}
try {
return Optional.of(UUID.fromString(
parameter.substring(separator + 1)));
} catch (IllegalArgumentException ignored) {
return Optional.empty();
}
}
return Optional.empty();
}
private IdentityLinkFailureCode mapIdentityCoreFailure(
IdentityCoreException exception) {
IdentityFailureCode code = exception.getReasonCode();
@ -338,6 +562,28 @@ public class CasLoginAppService {
};
}
private AccountMergeFailureCode mapAccountMergeIdentityFailure(
IdentityCoreException exception) {
return switch (exception.getReasonCode()) {
case PROVIDER_DISABLED,
PROVIDER_AUTHORITY_MISMATCH ->
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE;
case INVALID_IDENTITY_ASSERTION,
IDENTITY_SUBJECT_MISSING,
IDENTITY_IDENTIFIER_CONFLICT ->
AccountMergeFailureCode
.MERGE_PROVIDER_AUTHENTICATION_FAILED;
case ACCESS_DENIED,
ACCOUNT_PENDING,
ACCOUNT_DISABLED,
ACCOUNT_MERGED,
SYSTEM_ACCOUNT_FORBIDDEN ->
AccountMergeFailureCode
.MERGE_ACCOUNT_NOT_ELIGIBLE;
};
}
private String identityLinkFailureTarget(
UUID intentId,
IdentityLinkFailureCode reasonCode) {

View file

@ -15,6 +15,7 @@ import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest;
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
@ -38,16 +39,22 @@ public class IdentityLinkAppService {
private final ExternalIdentityLinkService externalLinkService;
private final IdentityProviderRegistry providerRegistry;
private final IdentityLinkSessionManager sessionManager;
private final AccountMergeSessionManager
accountMergeSessionManager;
public IdentityLinkAppService(
IdentityLinkIntentService intentService,
ExternalIdentityLinkService externalLinkService,
IdentityProviderRegistry providerRegistry,
IdentityLinkSessionManager sessionManager) {
IdentityLinkSessionManager sessionManager,
AccountMergeSessionManager
accountMergeSessionManager) {
this.intentService = intentService;
this.externalLinkService = externalLinkService;
this.providerRegistry = providerRegistry;
this.sessionManager = sessionManager;
this.accountMergeSessionManager =
accountMergeSessionManager;
}
public IdentityLinkAccountStateResponse accountState(
@ -191,6 +198,7 @@ public class IdentityLinkAppService {
intentId,
providerCode,
browserMethod);
accountMergeSessionManager.clearBrowserFlow(session);
sessionManager.prepareBrowserFlow(
session,
intentId,
@ -222,6 +230,7 @@ public class IdentityLinkAppService {
actor,
intentId,
browserMethod);
accountMergeSessionManager.clearBrowserFlow(session);
sessionManager.prepareBrowserFlow(
session,
intentId,

View file

@ -3,6 +3,8 @@ package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.identity.IdentityLinkException;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.auth.merge.AccountMergeException;
import com.iflytek.skillhub.auth.merge.AccountMergeFailureCode;
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
import org.springframework.http.HttpStatus;
@ -52,6 +54,25 @@ final class ProviderAuthenticationFailureMapper {
return new IdentityLinkException(reasonCode, exception);
}
static AccountMergeException mapAccountMerge(
ProviderAuthenticationException exception) {
AccountMergeFailureCode reasonCode =
switch (exception.getReasonCode()) {
case UPSTREAM_INVALID_CREDENTIALS,
UPSTREAM_ACCESS_DENIED,
REPLAY_DETECTED ->
AccountMergeFailureCode
.MERGE_PROVIDER_AUTHENTICATION_FAILED;
case UPSTREAM_UNAVAILABLE,
UPSTREAM_MISCONFIGURED,
TLS_VALIDATION_FAILED,
UPSTREAM_INVALID_RESPONSE ->
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE;
};
return new AccountMergeException(reasonCode, exception);
}
private static AuthFlowException failure(
HttpStatus status,
String messageCode) {

View file

@ -0,0 +1,41 @@
package com.iflytek.skillhub.task;
import com.iflytek.skillhub.auth.merge.AccountMergeIntentService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Marks expired account-merge intents terminal even when no later request
* reads them.
*/
@Component
public class AccountMergeIntentCleanupTask {
static final int BATCH_SIZE = 100;
private static final Logger log = LoggerFactory.getLogger(
AccountMergeIntentCleanupTask.class);
private final AccountMergeIntentService intentService;
public AccountMergeIntentCleanupTask(
AccountMergeIntentService intentService) {
this.intentService = intentService;
}
@Scheduled(
fixedDelayString =
"${skillhub.auth.account-merge."
+ "intent-cleanup-poll-interval-ms:60000}")
public void expireDueIntents() {
int expired = intentService.expireDueIntents(
BATCH_SIZE);
if (expired > 0) {
log.info(
"Expired {} account merge intents",
expired);
}
}
}

View file

@ -0,0 +1,165 @@
package com.iflytek.skillhub.task;
import com.iflytek.skillhub.auth.merge.AccountMergeMetrics;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.notification.sse.SseEmitterManager;
import com.iflytek.skillhub.repository.AccountMergeSessionRevocationRepository;
import com.iflytek.skillhub.repository.AccountMergeSessionRevocationRepository.Claim;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.Session;
import org.springframework.stereotype.Component;
/**
* Reliably deletes every Spring Session indexed to a merged secondary user.
*/
@Component
@ConditionalOnBean(FindByIndexNameSessionRepository.class)
public class AccountMergeSessionRevocationTask {
static final Duration LEASE_DURATION = Duration.ofMinutes(2);
static final int MAX_BATCH_SIZE = 10;
static final String SESSION_STORE_FAILURE =
"SESSION_STORE_FAILURE";
static final String SESSION_DELETE_INCOMPLETE =
"SESSION_DELETE_INCOMPLETE";
private static final Logger log = LoggerFactory.getLogger(
AccountMergeSessionRevocationTask.class);
private final AccountMergeSessionRevocationRepository repository;
private final FindByIndexNameSessionRepository<? extends Session>
sessionRepository;
private final SseEmitterManager sseEmitterManager;
private final AccountMergeMetrics metrics;
private final AuditLogService auditLogService;
private final Clock clock;
public AccountMergeSessionRevocationTask(
AccountMergeSessionRevocationRepository repository,
FindByIndexNameSessionRepository<? extends Session>
sessionRepository,
SseEmitterManager sseEmitterManager,
AccountMergeMetrics metrics,
AuditLogService auditLogService,
Clock clock) {
this.repository = repository;
this.sessionRepository = sessionRepository;
this.sseEmitterManager = sseEmitterManager;
this.metrics = metrics;
this.auditLogService = auditLogService;
this.clock = clock;
}
@Scheduled(
fixedDelayString =
"${skillhub.auth.account-merge."
+ "session-revocation.poll-interval-ms:5000}")
public void processDueRevocations() {
for (int processed = 0;
processed < MAX_BATCH_SIZE;
processed++) {
Instant now = Instant.now(clock);
var claim = repository.claimNext(
now,
LEASE_DURATION);
if (claim.isEmpty()) {
return;
}
process(claim.orElseThrow());
}
}
private void process(Claim claim) {
try {
sseEmitterManager.closeAll(claim.userId());
deleteIndexedSessions(claim.userId());
if (repository.complete(
claim,
Instant.now(clock))) {
metrics.recordSessionRevocation("success");
}
} catch (RuntimeException exception) {
Instant now = Instant.now(clock);
String errorCode =
exception instanceof IncompleteDeletionException
? SESSION_DELETE_INCOMPLETE
: SESSION_STORE_FAILURE;
boolean scheduled = repository.retry(
claim,
now.plus(backoff(claim.attemptCount())),
errorCode,
now);
if (scheduled) {
metrics.recordSessionRevocation("retry");
recordRetryAudit(claim, errorCode);
log.warn(
"Account merge session revocation will retry "
+ "[taskId={}, attempt={}, reason={}]",
claim.id(),
claim.attemptCount(),
errorCode);
}
}
}
private void recordRetryAudit(
Claim claim,
String errorCode) {
try {
auditLogService.record(
claim.userId(),
"ACCOUNT_MERGE_SESSION_REVOCATION_RETRIED",
"ACCOUNT_MERGE_SESSION_REVOCATION",
claim.id(),
null,
null,
null,
"{\"attempt\":"
+ claim.attemptCount()
+ ",\"reason\":\""
+ errorCode
+ "\"}");
} catch (RuntimeException exception) {
log.error(
"Failed to persist account merge session "
+ "revocation retry audit [taskId={}]",
claim.id(),
exception);
}
}
private void deleteIndexedSessions(String userId) {
Map<String, ? extends Session> sessions =
sessionRepository.findByPrincipalName(userId);
for (String sessionId : sessions.keySet()) {
sessionRepository.deleteById(sessionId);
}
if (!sessionRepository.findByPrincipalName(userId)
.isEmpty()) {
throw new IncompleteDeletionException();
}
}
static Duration backoff(int attemptCount) {
int exponent = Math.max(
0,
Math.min(attemptCount - 1, 6));
return Duration.ofSeconds(
Math.min(300L, 5L << exponent));
}
private static final class IncompleteDeletionException
extends RuntimeException {
private IncompleteDeletionException() {
super(SESSION_DELETE_INCOMPLETE);
}
}
}

View file

@ -47,6 +47,8 @@ spring:
store-type: redis
redis:
namespace: ${SESSION_REDIS_NAMESPACE:skillhub:session}
repository-type: ${SPRING_SESSION_REDIS_REPOSITORY_TYPE:default}
configure-action: ${SPRING_SESSION_REDIS_CONFIGURE_ACTION:notify-keyspace-events}
security:
oauth2:
client:
@ -108,6 +110,9 @@ skillhub:
auth:
mock:
enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false}
account-merge:
enabled: ${SKILLHUB_AUTH_ACCOUNT_MERGE_ENABLED:false}
session-cutover-complete: ${SKILLHUB_AUTH_ACCOUNT_MERGE_SESSION_CUTOVER_COMPLETE:false}
identity:
# Provider-specific overrides bind below providers.<registration-id>.
# Defaults: AUTO provisioning; PRESERVE_LOCAL displayName/avatarUrl;

View file

@ -0,0 +1,178 @@
CREATE TABLE account_merge_intent (
id UUID PRIMARY KEY,
primary_user_id VARCHAR(128) NOT NULL,
secondary_user_id VARCHAR(128),
status VARCHAR(32) NOT NULL,
primary_session_nonce_hash VARCHAR(64) NOT NULL,
primary_proof_method VARCHAR(96) NOT NULL,
primary_proof_at TIMESTAMPTZ NOT NULL,
secondary_proof_method VARCHAR(96),
secondary_proof_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
preview_version INTEGER,
preview_digest VARCHAR(64),
confirmed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
row_version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_account_merge_intent_primary_user
FOREIGN KEY (primary_user_id)
REFERENCES user_account(id),
CONSTRAINT fk_account_merge_intent_secondary_user
FOREIGN KEY (secondary_user_id)
REFERENCES user_account(id),
CONSTRAINT chk_account_merge_intent_distinct_users
CHECK (
secondary_user_id IS NULL
OR secondary_user_id <> primary_user_id
),
CONSTRAINT chk_account_merge_intent_status
CHECK (
status IN (
'PENDING_SECONDARY_PROOF',
'READY_FOR_PREVIEW',
'READY_TO_CONFIRM',
'COMPLETED',
'CANCELLED',
'EXPIRED',
'FAILED_CONFLICT'
)
),
CONSTRAINT chk_account_merge_intent_session_hash
CHECK (
primary_session_nonce_hash
~ '^[0-9a-f]{64}$'
),
CONSTRAINT chk_account_merge_intent_secondary_proof
CHECK (
(
secondary_user_id IS NULL
AND secondary_proof_method IS NULL
AND secondary_proof_at IS NULL
)
OR
(
secondary_user_id IS NOT NULL
AND secondary_proof_method IS NOT NULL
AND secondary_proof_at IS NOT NULL
)
),
CONSTRAINT chk_account_merge_intent_preview
CHECK (
(
preview_version IS NULL
AND preview_digest IS NULL
)
OR
(
preview_version > 0
AND preview_digest
~ '^[0-9a-f]{64}$'
)
),
CONSTRAINT chk_account_merge_intent_completion
CHECK (
(status = 'COMPLETED' AND completed_at IS NOT NULL)
OR
(status <> 'COMPLETED' AND completed_at IS NULL)
),
CONSTRAINT chk_account_merge_intent_cancellation
CHECK (
(status = 'CANCELLED' AND cancelled_at IS NOT NULL)
OR
(status <> 'CANCELLED' AND cancelled_at IS NULL)
)
);
CREATE UNIQUE INDEX uq_account_merge_intent_active_primary
ON account_merge_intent(primary_user_id)
WHERE status IN (
'PENDING_SECONDARY_PROOF',
'READY_FOR_PREVIEW',
'READY_TO_CONFIRM',
'FAILED_CONFLICT'
);
CREATE UNIQUE INDEX uq_account_merge_intent_active_secondary
ON account_merge_intent(secondary_user_id)
WHERE secondary_user_id IS NOT NULL
AND status IN (
'PENDING_SECONDARY_PROOF',
'READY_FOR_PREVIEW',
'READY_TO_CONFIRM',
'FAILED_CONFLICT'
);
CREATE INDEX idx_account_merge_intent_expiry
ON account_merge_intent(expires_at)
WHERE status IN (
'PENDING_SECONDARY_PROOF',
'READY_FOR_PREVIEW',
'READY_TO_CONFIRM',
'FAILED_CONFLICT'
);
CREATE INDEX idx_account_merge_intent_secondary_user
ON account_merge_intent(secondary_user_id)
WHERE secondary_user_id IS NOT NULL;
CREATE TABLE account_merge_session_revocation (
id BIGSERIAL PRIMARY KEY,
merge_intent_id UUID NOT NULL,
user_id VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL,
lease_until TIMESTAMPTZ,
last_error_code VARCHAR(64),
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_account_merge_session_revocation_intent
FOREIGN KEY (merge_intent_id)
REFERENCES account_merge_intent(id),
CONSTRAINT fk_account_merge_session_revocation_user
FOREIGN KEY (user_id)
REFERENCES user_account(id),
CONSTRAINT uq_account_merge_session_revocation_intent
UNIQUE (merge_intent_id),
CONSTRAINT chk_account_merge_session_revocation_status
CHECK (
status IN (
'PENDING',
'PROCESSING',
'COMPLETED'
)
),
CONSTRAINT chk_account_merge_session_revocation_attempt
CHECK (attempt_count >= 0),
CONSTRAINT chk_account_merge_session_revocation_lifecycle
CHECK (
(
status = 'PENDING'
AND lease_until IS NULL
AND completed_at IS NULL
)
OR
(
status = 'PROCESSING'
AND lease_until IS NOT NULL
AND completed_at IS NULL
)
OR
(
status = 'COMPLETED'
AND lease_until IS NULL
AND completed_at IS NOT NULL
)
)
);
CREATE INDEX idx_account_merge_session_revocation_due
ON account_merge_session_revocation(
next_attempt_at,
id
)
WHERE status IN ('PENDING', 'PROCESSING');

View file

@ -53,6 +53,22 @@ error.auth.external.accessDenied=External account access is denied
error.auth.external.accountPending=The external account is pending approval
error.auth.external.linkRequired=Additional account verification is required
error.auth.merge.temporarilyUnavailable=Account merging is temporarily unavailable while the ownership verification flow is being secured
error.auth.accountMerge.unavailable=Safe account merging is not enabled
error.auth.accountMerge.intentNotFound=Account merge intent was not found
error.auth.accountMerge.reauthenticationRequired=Fresh reauthentication is required before account merge
error.auth.accountMerge.providerAuthenticationFailed=The identity provider could not verify this account
error.auth.accountMerge.providerUnavailable=The identity provider is unavailable for account merge
error.auth.accountMerge.sessionMismatch=Account merge intent belongs to another session
error.auth.accountMerge.proofExpired=Account merge proof has expired
error.auth.accountMerge.conflict=Account merge is blocked by a conflicting active intent or account state
error.auth.accountMerge.previewStale=Account merge preview is stale
error.auth.accountMerge.alreadyConsumed=Account merge intent has already been consumed
error.auth.accountMerge.accountNotEligible=This account is not eligible for account merge
error.auth.accountMerge.invalidOperation=Invalid account merge operation
auth.accountMerge.method.localPassword=Local password
validation.auth.accountMerge.password.notBlank=Password is required
validation.auth.accountMerge.username.notBlank=Username is required
validation.auth.accountMerge.username.size=Username must not exceed 64 characters
error.badRequest=Invalid request
error.forbidden=Forbidden
error.apiToken.scope.missing=API token is missing required scope: {0}

View file

@ -53,6 +53,22 @@ error.auth.external.accessDenied=外部账号无权访问
error.auth.external.accountPending=外部账号正在等待审批
error.auth.external.linkRequired=需要完成额外的账号验证
error.auth.merge.temporarilyUnavailable=账号合并功能正在进行安全升级,暂时不可用
error.auth.accountMerge.unavailable=安全账号合并功能尚未启用
error.auth.accountMerge.intentNotFound=未找到账号合并意图
error.auth.accountMerge.reauthenticationRequired=账号合并前需要重新验证当前账号
error.auth.accountMerge.providerAuthenticationFailed=身份提供方未能验证该账号
error.auth.accountMerge.providerUnavailable=身份提供方暂时无法用于账号合并
error.auth.accountMerge.sessionMismatch=账号合并意图不属于当前会话
error.auth.accountMerge.proofExpired=账号合并证明已过期
error.auth.accountMerge.conflict=存在进行中的合并意图或账号状态冲突
error.auth.accountMerge.previewStale=账号合并预览已失效
error.auth.accountMerge.alreadyConsumed=账号合并意图已被使用
error.auth.accountMerge.accountNotEligible=当前账号不能执行账号合并
error.auth.accountMerge.invalidOperation=账号合并操作无效
auth.accountMerge.method.localPassword=本地密码
validation.auth.accountMerge.password.notBlank=密码不能为空
validation.auth.accountMerge.username.notBlank=用户名不能为空
validation.auth.accountMerge.username.size=用户名不能超过 64 个字符
error.badRequest=请求参数不合法
error.forbidden=没有权限执行该操作
error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0}

View file

@ -255,7 +255,6 @@ class IdentityLinkMigrationLoginPostgresIntegrationTest {
.schemas(SCHEMA)
.defaultSchema(SCHEMA)
.createSchemas(true)
.target(MigrationVersion.fromVersion("49"))
.load()
.migrate();
}

View file

@ -0,0 +1,235 @@
package com.iflytek.skillhub.auth.merge;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
@EnabledIfEnvironmentVariable(
named = "IDENTITY_BINDING_V2_POSTGRES_URL",
matches = "jdbc:postgresql:.*")
class AccountMergeIntentMigrationPostgresTest {
private static final String SCHEMA =
"account_merge_v50_migration";
@Test
void upgradesAdditivelyAndLeavesLegacyRequestsIsolated()
throws Exception {
String url = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_URL");
String username = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_USERNAME");
String password = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_PASSWORD");
dropSchema(url, username, password);
try {
migrateTo(url, username, password, "49");
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute(
"SET search_path TO " + SCHEMA);
statement.executeUpdate("""
INSERT INTO user_account (
id,
display_name,
status,
created_at,
updated_at
) VALUES
(
'merge-primary',
'Merge Primary',
'ACTIVE',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
),
(
'merge-secondary',
'Merge Secondary',
'ACTIVE',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""");
statement.executeUpdate("""
INSERT INTO account_merge_request (
primary_user_id,
secondary_user_id,
status,
verification_token,
token_expires_at,
created_at
) VALUES (
'merge-primary',
'merge-secondary',
'VERIFIED',
'legacy-proof-must-not-migrate',
CURRENT_TIMESTAMP
+ INTERVAL '10 minutes',
CURRENT_TIMESTAMP
)
""");
}
migrateTo(url, username, password, "50");
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute(
"SET search_path TO " + SCHEMA);
assertThat(singleLong(
statement,
"""
SELECT COUNT(*)
FROM account_merge_request
WHERE primary_user_id = 'merge-primary'
AND secondary_user_id = 'merge-secondary'
AND status = 'VERIFIED'
AND verification_token =
'legacy-proof-must-not-migrate'
""")).isEqualTo(1L);
assertThat(singleLong(
statement,
"""
SELECT COUNT(*)
FROM account_merge_intent
""")).isZero();
statement.executeUpdate("""
INSERT INTO account_merge_intent (
id,
primary_user_id,
status,
primary_session_nonce_hash,
primary_proof_method,
primary_proof_at,
expires_at
) VALUES (
'03ea32a0-f0bf-4b3c-966c-bca7bb58381b',
'merge-primary',
'PENDING_SECONDARY_PROOF',
repeat('a', 64),
'local-password',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
+ INTERVAL '10 minutes'
)
""");
assertThatThrownBy(() ->
statement.executeUpdate("""
INSERT INTO account_merge_intent (
id,
primary_user_id,
status,
primary_session_nonce_hash,
primary_proof_method,
primary_proof_at,
expires_at
) VALUES (
'ff911729-6741-4290-908a-5b0ef64191fb',
'merge-primary',
'PENDING_SECONDARY_PROOF',
repeat('b', 64),
'local-password',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
+ INTERVAL '10 minutes'
)
"""))
.hasMessageContaining(
"uq_account_merge_intent_active_primary");
assertThatThrownBy(() ->
statement.executeUpdate("""
UPDATE account_merge_intent
SET
secondary_user_id =
primary_user_id,
secondary_proof_method =
'local-password',
secondary_proof_at =
CURRENT_TIMESTAMP,
status = 'READY_FOR_PREVIEW'
WHERE id =
'03ea32a0-f0bf-4b3c-966c-bca7bb58381b'
"""))
.hasMessageContaining(
"chk_account_merge_intent_distinct_users");
}
} finally {
dropSchema(url, username, password);
}
}
private static void migrateTo(
String url,
String username,
String password,
String version) {
Flyway.configure()
.dataSource(url, username, password)
.locations("classpath:db/migration")
.schemas(SCHEMA)
.defaultSchema(SCHEMA)
.createSchemas(true)
.target(MigrationVersion.fromVersion(version))
.load()
.migrate();
}
private static long singleLong(
Statement statement,
String sql) throws Exception {
try (ResultSet result =
statement.executeQuery(sql)) {
assertThat(result.next()).isTrue();
return result.getLong(1);
}
}
private static String requiredEnvironment(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"Missing required environment variable "
+ name);
}
return value;
}
private static void dropSchema(
String url,
String username,
String password) throws Exception {
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute(
"DROP SCHEMA IF EXISTS "
+ SCHEMA
+ " CASCADE");
}
}
}

View file

@ -0,0 +1,95 @@
package com.iflytek.skillhub.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import com.iflytek.skillhub.auth.merge.AccountMergeProperties;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.session.FindByIndexNameSessionRepository;
class AccountMergeSessionRevocationReadinessTest {
private static final String ENABLED =
"skillhub.auth.account-merge.enabled=true";
private final ApplicationContextRunner contextRunner =
new ApplicationContextRunner()
.withUserConfiguration(
AccountMergeSessionRevocationReadiness.class);
@Test
void enablingAccountMergeWithoutIndexedSessionsFailsStartup() {
contextRunner
.withPropertyValues(ENABLED)
.withBean(
AccountMergeProperties.class,
() -> properties(true))
.run(context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.hasMessageContaining(
"FindByIndexNameSessionRepository");
});
}
@Test
void disabledAccountMergeDoesNotRequireIndexedSessions() {
contextRunner.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).doesNotHaveBean(
AccountMergeSessionRevocationReadiness.class);
});
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void enablingAccountMergeWithIndexedSessionsIsReady() {
contextRunner
.withPropertyValues(
ENABLED)
.withBean(
AccountMergeProperties.class,
() -> properties(true))
.withBean(
FindByIndexNameSessionRepository.class,
() -> mock(
FindByIndexNameSessionRepository.class))
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(
AccountMergeSessionRevocationReadiness.class);
});
}
@Test
@SuppressWarnings({ "rawtypes", "unchecked" })
void enablingAccountMergeBeforeSessionCutoverFailsStartup() {
contextRunner
.withPropertyValues(ENABLED)
.withBean(
AccountMergeProperties.class,
() -> properties(false))
.withBean(
FindByIndexNameSessionRepository.class,
() -> mock(
FindByIndexNameSessionRepository.class))
.run(context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.rootCause()
.hasMessageContaining(
"namespace cutover");
});
}
private static AccountMergeProperties properties(
boolean sessionCutoverComplete) {
AccountMergeProperties properties =
new AccountMergeProperties();
properties.setEnabled(true);
properties.setSessionCutoverComplete(
sessionCutoverComplete);
return properties;
}
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.config;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
@ -18,9 +19,13 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;
import org.springframework.session.data.redis.RedisIndexedSessionRepository;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@ -99,14 +104,33 @@ class RedisClusterIntegrationTest {
SessionRepository<Session> sessionRepository = sessionRepository(repository);
Session session = sessionRepository.createSession();
session.setAttribute("userId", "cluster-user");
PlatformPrincipal principal = new PlatformPrincipal(
"cluster-user",
"Cluster User",
"cluster@example.com",
null,
"local",
Set.of("USER"));
session.setAttribute(
HttpSessionSecurityContextRepository
.SPRING_SECURITY_CONTEXT_KEY,
new SecurityContextImpl(
new UsernamePasswordAuthenticationToken(
principal,
null,
List.of())));
sessionRepository.save(session);
Session loaded = sessionRepository.findById(session.getId());
assertThat(loaded).isNotNull();
assertThat(loaded.<String>getAttribute("userId")).isEqualTo("cluster-user");
assertThat(repository.findByPrincipalName("cluster-user"))
.containsKey(session.getId());
sessionRepository.deleteById(session.getId());
assertThat(sessionRepository.findById(session.getId())).isNull();
assertThat(repository.findByPrincipalName("cluster-user"))
.isEmpty();
} finally {
repository.destroy();
}

View file

@ -0,0 +1,147 @@
package com.iflytek.skillhub.controller;
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.get;
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.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@TestPropertySource(properties = {
"skillhub.auth.account-merge.enabled=true",
"skillhub.auth.account-merge."
+ "session-cutover-complete=true"
})
class AccountMergeControllerEnabledTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
@SuppressWarnings("rawtypes")
private FindByIndexNameSessionRepository sessionRepository;
@Autowired
private UserAccountRepository userAccountRepository;
@BeforeEach
void seedPrimaryAccount() {
if (userAccountRepository.findById("usr_primary")
.isEmpty()) {
userAccountRepository.save(new UserAccount(
"usr_primary",
"Primary",
"primary@example.com",
null));
}
}
@Test
void capabilitiesExposeTheEnabledResourceApi() throws Exception {
mockMvc.perform(
get("/api/v1/account/merge/capabilities")
.session(primarySession())
.with(authentication(
primaryAuthentication())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.enabled").value(true))
.andExpect(jsonPath("$.data.primaryMethods")
.isArray())
.andExpect(jsonPath("$.data.secondaryMethods")
.isArray());
}
@Test
void resourceMutationStillRequiresCsrf() throws Exception {
mockMvc.perform(
post("/api/v1/account/merge/intents")
.session(primarySession())
.with(authentication(
primaryAuthentication())))
.andExpect(status().isUnauthorized());
}
@Test
void intentCreationRequiresAConsumedFreshPrimaryProof()
throws Exception {
mockMvc.perform(
post("/api/v1/account/merge/intents")
.session(primarySession())
.with(authentication(
primaryAuthentication()))
.with(csrf()))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.reasonCode").value(
"MERGE_REAUTH_REQUIRED"));
}
@Test
void enablingTheNewFlowDoesNotReviveLegacyTokenEndpoints()
throws Exception {
mockMvc.perform(
post("/api/v1/account/merge/initiate")
.session(primarySession())
.with(authentication(
primaryAuthentication()))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"secondaryIdentifier": "secondary"
}
"""))
.andExpect(status().isServiceUnavailable());
}
private UsernamePasswordAuthenticationToken
primaryAuthentication() {
return new UsernamePasswordAuthenticationToken(
principal(),
null,
List.of());
}
private MockHttpSession primarySession() {
MockHttpSession session = new MockHttpSession();
session.setAttribute(
"platformPrincipal",
principal());
return session;
}
private PlatformPrincipal principal() {
return new PlatformPrincipal(
"usr_primary",
"Primary",
"primary@example.com",
null,
"local",
Set.of());
}
}

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller;
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.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -11,6 +12,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.springframework.mock.web.MockHttpSession;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@ -108,6 +110,40 @@ class AccountMergeControllerTest {
.andExpect(status().isUnauthorized());
}
@Test
void safeIntentEndpointRemainsUnavailableByDefault()
throws Exception {
mockMvc.perform(
post("/api/v1/account/merge/intents")
.session(primarySession())
.with(authentication(
primaryAuthentication()))
.with(csrf())
.locale(Locale.ENGLISH))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value(503))
.andExpect(jsonPath("$.reasonCode").value(
"ACCOUNT_MERGE_UNAVAILABLE"));
}
@Test
void capabilitiesReportTheReleaseGateAsDisabledByDefault()
throws Exception {
mockMvc.perform(
get("/api/v1/account/merge/capabilities")
.session(primarySession())
.with(authentication(
primaryAuthentication()))
.locale(Locale.ENGLISH))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.enabled")
.value(false))
.andExpect(jsonPath("$.data.primaryMethods")
.isArray())
.andExpect(jsonPath("$.data.secondaryMethods")
.isArray());
}
private UsernamePasswordAuthenticationToken primaryAuthentication() {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_primary",
@ -119,4 +155,12 @@ class AccountMergeControllerTest {
);
return new UsernamePasswordAuthenticationToken(principal, null, List.of());
}
private MockHttpSession primarySession() {
MockHttpSession session = new MockHttpSession();
session.setAttribute(
"platformPrincipal",
primaryAuthentication().getPrincipal());
return session;
}
}

View file

@ -93,6 +93,46 @@ class AuthContextFilterTest {
verify(filterChain, never()).doFilter(request, response);
}
@Test
void mergedSecondarySession_shouldInvalidateSessionAndBlockNextRequest() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"user-merged",
"Merged",
"merged@example.com",
null,
"local",
Set.of("USER"));
UserAccount user = new UserAccount(
"user-merged",
"Merged",
"merged@example.com",
null);
user.setStatus(UserStatus.MERGED);
user.setMergedToUserId("user-primary");
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/api/v1/auth/me");
MockHttpSession session = (MockHttpSession) request.getSession(true);
session.setAttribute("platformPrincipal", principal);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, List.of())
);
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain filterChain = mock(FilterChain.class);
when(userAccountRepository.findById("user-merged"))
.thenReturn(java.util.Optional.of(user));
filter.doFilter(request, response, filterChain);
assertEquals(401, response.getStatus());
assertTrue(response.getContentAsString().contains("\"code\":401"));
assertTrue(session.isInvalid());
assertNull(SecurityContextHolder.getContext().getAuthentication());
verify(filterChain, never()).doFilter(request, response);
}
@Test
void activeSessionUser_shouldPopulateRequestContextAndContinue() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("user-2", "Bob", "bob@example.com", null, "local", Set.of("USER"));

View file

@ -0,0 +1,330 @@
package com.iflytek.skillhub.repository;
import static org.assertj.core.api.Assertions.assertThat;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.time.Duration;
import java.time.Instant;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
@EnabledIfEnvironmentVariable(
named = "IDENTITY_BINDING_V2_POSTGRES_URL",
matches = "jdbc:postgresql:.*")
class AccountMergeSessionRevocationRepositoryPostgresTest {
private static final String SCHEMA =
"account_merge_session_revocation_repository";
private static final Instant NOW =
Instant.parse("2026-07-31T10:00:00Z");
@Test
void claimsCompletesAndRetriesOnlyDueLeases()
throws Exception {
String url = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_URL");
String username = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_USERNAME");
String password = requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_PASSWORD");
dropSchema(url, username, password);
try {
Flyway.configure()
.dataSource(url, username, password)
.locations("classpath:db/migration")
.schemas(SCHEMA)
.defaultSchema(SCHEMA)
.createSchemas(true)
.load()
.migrate();
seed(url, username, password);
AccountMergeSessionRevocationRepository repository =
repository(url, username, password);
var first = repository.claimNext(
NOW,
Duration.ofMinutes(2)).orElseThrow();
assertThat(first.id()).isEqualTo(1L);
assertThat(first.attemptCount()).isEqualTo(1);
assertThat(repository.complete(first, NOW))
.isTrue();
var reclaimed = repository.claimNext(
NOW,
Duration.ofMinutes(2)).orElseThrow();
assertThat(reclaimed.id()).isEqualTo(3L);
assertThat(reclaimed.attemptCount()).isEqualTo(5);
assertThat(repository.retry(
reclaimed,
NOW.plusSeconds(80),
"SESSION_STORE_FAILURE",
NOW)).isTrue();
assertThat(repository.claimNext(
NOW,
Duration.ofMinutes(2))).isEmpty();
assertState(
url,
username,
password);
} finally {
dropSchema(url, username, password);
}
}
private static AccountMergeSessionRevocationRepository
repository(
String url,
String username,
String password) {
DriverManagerDataSource dataSource =
new DriverManagerDataSource(
schemaUrl(url),
username,
password);
return new AccountMergeSessionRevocationRepository(
new JdbcTemplate(dataSource));
}
private static void seed(
String url,
String username,
String password) throws Exception {
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute("SET search_path TO " + SCHEMA);
statement.executeUpdate("""
INSERT INTO user_account (
id,
display_name,
status,
created_at,
updated_at
) VALUES
(
'merge-primary',
'Merge Primary',
'ACTIVE',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
),
(
'merge-secondary',
'Merge Secondary',
'MERGED',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""");
statement.executeUpdate("""
INSERT INTO account_merge_intent (
id,
primary_user_id,
secondary_user_id,
status,
primary_session_nonce_hash,
primary_proof_method,
primary_proof_at,
secondary_proof_method,
secondary_proof_at,
expires_at,
completed_at
) VALUES (
'03ea32a0-f0bf-4b3c-966c-bca7bb58381b',
'merge-primary',
'merge-secondary',
'COMPLETED',
repeat('a', 64),
'local-password',
TIMESTAMPTZ '2026-07-31 09:55:00Z',
'provider:github',
TIMESTAMPTZ '2026-07-31 09:56:00Z',
TIMESTAMPTZ '2026-07-31 10:05:00Z',
TIMESTAMPTZ '2026-07-31 09:59:00Z'
)
""");
statement.executeUpdate("""
INSERT INTO account_merge_session_revocation (
merge_intent_id,
user_id,
status,
attempt_count,
next_attempt_at,
lease_until,
created_at,
updated_at
) VALUES
(
'03ea32a0-f0bf-4b3c-966c-bca7bb58381b',
'merge-secondary',
'PENDING',
0,
TIMESTAMPTZ '2026-07-31 09:59:00Z',
NULL,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""");
statement.executeUpdate("""
INSERT INTO account_merge_intent (
id,
primary_user_id,
secondary_user_id,
status,
primary_session_nonce_hash,
primary_proof_method,
primary_proof_at,
secondary_proof_method,
secondary_proof_at,
expires_at,
completed_at
) VALUES
(
'13ea32a0-f0bf-4b3c-966c-bca7bb58381b',
'merge-primary',
'merge-secondary',
'COMPLETED',
repeat('b', 64),
'local-password',
TIMESTAMPTZ '2026-07-31 09:55:00Z',
'provider:github',
TIMESTAMPTZ '2026-07-31 09:56:00Z',
TIMESTAMPTZ '2026-07-31 10:05:00Z',
TIMESTAMPTZ '2026-07-31 09:59:00Z'
),
(
'23ea32a0-f0bf-4b3c-966c-bca7bb58381b',
'merge-primary',
'merge-secondary',
'COMPLETED',
repeat('c', 64),
'local-password',
TIMESTAMPTZ '2026-07-31 09:55:00Z',
'provider:github',
TIMESTAMPTZ '2026-07-31 09:56:00Z',
TIMESTAMPTZ '2026-07-31 10:05:00Z',
TIMESTAMPTZ '2026-07-31 09:59:00Z'
)
""");
statement.executeUpdate("""
INSERT INTO account_merge_session_revocation (
merge_intent_id,
user_id,
status,
attempt_count,
next_attempt_at,
lease_until,
created_at,
updated_at
) VALUES
(
'13ea32a0-f0bf-4b3c-966c-bca7bb58381b',
'merge-secondary',
'PENDING',
0,
TIMESTAMPTZ '2026-07-31 11:00:00Z',
NULL,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
),
(
'23ea32a0-f0bf-4b3c-966c-bca7bb58381b',
'merge-secondary',
'PROCESSING',
4,
TIMESTAMPTZ '2026-07-31 09:00:00Z',
TIMESTAMPTZ '2026-07-31 09:59:00Z',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""");
}
}
private static void assertState(
String url,
String username,
String password) throws Exception {
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute("SET search_path TO " + SCHEMA);
try (ResultSet result = statement.executeQuery("""
SELECT id,
status,
attempt_count,
last_error_code
FROM account_merge_session_revocation
ORDER BY id
""")) {
assertThat(result.next()).isTrue();
assertThat(result.getString("status"))
.isEqualTo("COMPLETED");
assertThat(result.getInt("attempt_count"))
.isEqualTo(1);
assertThat(result.next()).isTrue();
assertThat(result.getString("status"))
.isEqualTo("PENDING");
assertThat(result.getInt("attempt_count"))
.isZero();
assertThat(result.next()).isTrue();
assertThat(result.getString("status"))
.isEqualTo("PENDING");
assertThat(result.getInt("attempt_count"))
.isEqualTo(5);
assertThat(result.getString("last_error_code"))
.isEqualTo("SESSION_STORE_FAILURE");
assertThat(result.next()).isFalse();
}
}
}
private static String schemaUrl(String url) {
return url + (url.contains("?") ? "&" : "?")
+ "currentSchema="
+ SCHEMA;
}
private static String requiredEnvironment(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"Missing required environment variable "
+ name);
}
return value;
}
private static void dropSchema(
String url,
String username,
String password) throws Exception {
try (Connection connection =
DriverManager.getConnection(
url,
username,
password);
Statement statement =
connection.createStatement()) {
statement.execute(
"DROP SCHEMA IF EXISTS "
+ SCHEMA
+ " CASCADE");
}
}
}

View file

@ -0,0 +1,340 @@
package com.iflytek.skillhub.service;
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.IdentityLinkAccountState;
import com.iflytek.skillhub.auth.identity.IdentityLinkBindingView;
import com.iflytek.skillhub.auth.identity.IdentityLinkIntentService;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod;
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.merge.AccountMergeException;
import com.iflytek.skillhub.auth.merge.AccountMergeFailureCode;
import com.iflytek.skillhub.auth.merge.AccountMergeIntent;
import com.iflytek.skillhub.auth.merge.AccountMergeIntentService;
import com.iflytek.skillhub.auth.merge.AccountMergeIntentStatus;
import com.iflytek.skillhub.auth.merge.AccountMergeMetrics;
import com.iflytek.skillhub.auth.merge.AccountMergeProviderProofService;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.AccountMergeIntentResponse;
import java.util.List;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.context.support.StaticMessageSource;
class AccountMergeAppServiceTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-07-31T09:00:00Z"),
ZoneOffset.UTC);
private static final IdentityLoginContext CONTEXT =
new IdentityLoginContext(
"req-merge-1",
"203.0.113.10",
"Browser");
private AccountMergeIntentService intentService;
private LocalAuthService localAuthService;
private IdentityProviderRegistry providerRegistry;
private IdentityLinkIntentService identityLinkIntentService;
private IdentityLinkSessionManager identityLinkSessionManager;
private AccountMergeSessionManager accountMergeSessionManager;
private AccountMergeMetrics metrics;
private AccountMergeAppService service;
private MockHttpSession session;
private PlatformPrincipal principal;
@BeforeEach
void setUp() {
intentService = mock(AccountMergeIntentService.class);
localAuthService = mock(LocalAuthService.class);
providerRegistry = mock(IdentityProviderRegistry.class);
identityLinkIntentService =
mock(IdentityLinkIntentService.class);
identityLinkSessionManager =
mock(IdentityLinkSessionManager.class);
accountMergeSessionManager =
new AccountMergeSessionManager(CLOCK);
metrics = mock(AccountMergeMetrics.class);
StaticMessageSource messageSource =
new StaticMessageSource();
messageSource.addMessage(
"auth.accountMerge.method.localPassword",
java.util.Locale.ENGLISH,
"Local password");
service = new AccountMergeAppService(
intentService,
localAuthService,
accountMergeSessionManager,
mock(AccountMergeProviderProofService.class),
providerRegistry,
identityLinkIntentService,
identityLinkSessionManager,
metrics,
messageSource);
principal = new PlatformPrincipal(
"usr_primary",
"Primary",
"primary@example.com",
null,
"local",
Set.of("USER"));
session = new MockHttpSession();
session.setAttribute("platformPrincipal", principal);
}
@Test
void intentCannotBeCreatedBeforeFreshReauthentication() {
assertThatThrownBy(() ->
service.createIntent(session, CONTEXT))
.isInstanceOfSatisfying(
AccountMergeException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED));
}
@Test
void localReauthenticationProofIsConsumedByIntentCreation() {
when(localAuthService.reauthenticate(
"usr_primary",
"correct-password")).thenReturn(principal);
when(intentService.createIntent(
any(),
any())).thenAnswer(invocation -> {
UUID intentId = invocation.getArgument(1);
return new AccountMergeIntent(
intentId,
AccountMergeIntentStatus
.PENDING_SECONDARY_PROOF,
Instant.parse(
"2026-07-31T09:10:00Z"));
});
service.reauthenticatePrimaryLocal(
"correct-password",
session);
AccountMergeIntentResponse response =
service.createIntent(session, CONTEXT);
assertThat(response.status()).isEqualTo(
AccountMergeIntentStatus
.PENDING_SECONDARY_PROOF);
verify(localAuthService).reauthenticate(
"usr_primary",
"correct-password");
verify(intentService).createIntent(any(), any());
assertThatThrownBy(() ->
service.createIntent(session, CONTEXT))
.isInstanceOfSatisfying(
AccountMergeException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED));
}
@Test
void secondaryLocalAuthenticationDoesNotReplacePrimarySession() {
PlatformPrincipal secondary = new PlatformPrincipal(
"usr_secondary",
"Secondary",
"secondary@example.com",
null,
"local",
Set.of("USER"));
when(localAuthService.reauthenticate(
"usr_primary",
"primary-password")).thenReturn(principal);
when(localAuthService.login(
"secondary-user",
"secondary-password")).thenReturn(secondary);
when(intentService.createIntent(
any(),
any())).thenAnswer(invocation ->
intent(
invocation.getArgument(1),
AccountMergeIntentStatus
.PENDING_SECONDARY_PROOF));
when(intentService.recordSecondaryProof(
any(),
any(),
any(),
any())).thenAnswer(invocation ->
intent(
invocation.getArgument(1),
AccountMergeIntentStatus
.READY_FOR_PREVIEW));
service.reauthenticatePrimaryLocal(
"primary-password",
session);
UUID intentId =
service.createIntent(session, CONTEXT).id();
AccountMergeIntentResponse response =
service.authenticateSecondaryLocal(
intentId,
"secondary-user",
"secondary-password",
session,
CONTEXT);
assertThat(response.status()).isEqualTo(
AccountMergeIntentStatus.READY_FOR_PREVIEW);
assertThat(session.getAttribute("platformPrincipal"))
.isSameAs(principal);
verify(intentService).recordSecondaryProof(
any(),
org.mockito.ArgumentMatchers.eq(intentId),
org.mockito.ArgumentMatchers.eq("usr_secondary"),
org.mockito.ArgumentMatchers.eq("local-password"));
}
@Test
void invalidPasswordDoesNotLeaveAUsablePrimaryProof() {
when(localAuthService.reauthenticate(
"usr_primary",
"wrong-password")).thenThrow(
new AuthFlowException(
HttpStatus.UNAUTHORIZED,
"error.auth.local.invalidCredentials"));
assertThatThrownBy(() ->
service.reauthenticatePrimaryLocal(
"wrong-password",
session))
.isInstanceOfSatisfying(
AccountMergeException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED));
assertThatThrownBy(() ->
service.createIntent(session, CONTEXT))
.isInstanceOf(AccountMergeException.class);
verify(metrics).record(
"proof",
"primary_local_failure");
}
@Test
void capabilitiesExposeOnlyUsableFreshAuthenticationMethods() {
when(intentService.isAvailable()).thenReturn(true);
when(identityLinkIntentService.accountState(
"usr_primary")).thenReturn(
new IdentityLinkAccountState(
true,
List.of(
new IdentityLinkBindingView(
1L,
"github",
"GitHub",
Set.of(
IdentityProviderLoginMethodType
.OAUTH_REDIRECT),
true,
true),
new IdentityLinkBindingView(
2L,
"disabled",
"Disabled",
Set.of(),
false,
true)),
List.of()));
when(providerRegistry.listReadyLoginMethods())
.thenReturn(List.of(
new IdentityProviderLoginMethod(
"cas-main",
"Corporate CAS",
IdentityProviderLoginMethodType
.CAS_REDIRECT),
new IdentityProviderLoginMethod(
"bootstrap",
"Bootstrap",
IdentityProviderLoginMethodType
.SESSION_BOOTSTRAP)));
var capabilities = service.capabilities(session);
assertThat(capabilities.enabled()).isTrue();
assertThat(capabilities.primaryMethods())
.extracting("providerCode", "methodType")
.containsExactly(
org.assertj.core.groups.Tuple.tuple(
"github",
"OAUTH_REDIRECT"),
org.assertj.core.groups.Tuple.tuple(
"local",
"LOCAL_PASSWORD"));
assertThat(capabilities.secondaryMethods())
.extracting("providerCode", "methodType")
.containsExactly(
org.assertj.core.groups.Tuple.tuple(
"cas-main",
"CAS_REDIRECT"),
org.assertj.core.groups.Tuple.tuple(
"local",
"LOCAL_PASSWORD"));
}
@Test
void primaryBrowserFlowUsesOnlyALinkedProviderAndClearsIdentityLinkState() {
when(identityLinkIntentService.accountState(
"usr_primary")).thenReturn(
new IdentityLinkAccountState(
false,
List.of(
new IdentityLinkBindingView(
1L,
"github",
"GitHub",
Set.of(
IdentityProviderLoginMethodType
.OAUTH_REDIRECT),
true,
true)),
List.of()));
var started = service.reauthenticatePrimaryBrowser(
"github",
session);
assertThat(started.actionUrl())
.startsWith(
"/oauth2/authorization/github");
verify(identityLinkSessionManager)
.clearBrowserFlow(session);
}
private AccountMergeIntent intent(
UUID intentId,
AccountMergeIntentStatus status) {
return new AccountMergeIntent(
intentId,
status,
Instant.parse("2026-07-31T09:10:00Z"));
}
}

View file

@ -27,6 +27,14 @@ import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
import com.iflytek.skillhub.auth.merge.AccountMergeProviderProofService;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import com.iflytek.skillhub.auth.merge.AccountMergeActor;
import com.iflytek.skillhub.auth.merge.AccountMergeBrowserFlow;
import com.iflytek.skillhub.auth.merge.AccountMergeIntent;
import com.iflytek.skillhub.auth.merge.AccountMergeIntentStatus;
import com.iflytek.skillhub.auth.merge.AccountMergePrimaryProof;
import com.iflytek.skillhub.auth.merge.AccountMergeProviderPrimaryProof;
import com.iflytek.skillhub.auth.provider.BrowserAuthenticationAdapter;
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
@ -40,6 +48,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.mock.web.MockHttpServletRequest;
@ -153,6 +162,11 @@ class CasLoginAppServiceTest {
request.getSession(),
PROVIDER,
STATE);
verify(fixture.accountMergeSessionManager)
.activateBrowserFlow(
request.getSession(),
PROVIDER,
STATE);
}
@Test
@ -329,6 +343,152 @@ class CasLoginAppServiceTest {
.remove(any(), any());
}
@Test
void completesPrimaryAccountMergeProofWithoutReplacingSession() {
Fixture fixture = new Fixture();
MockHttpServletRequest request = request();
CasAuthenticationExchange exchange =
new CasAuthenticationExchange(
"user-1",
Map.of(),
Instant.parse(
"2026-07-31T00:00:00Z"));
ProviderAuthenticationResult result = result();
AccountMergeBrowserFlow flow =
new AccountMergeBrowserFlow.Primary(
"usr_primary",
PROVIDER);
when(fixture.stateStore.consume(
request.getSession().getId(),
STATE)).thenReturn(consumedState());
when(fixture.accountMergeSessionManager
.consumeBrowserFlow(
eq(request),
eq(PROVIDER),
any(IdentityLoginContext.class)))
.thenReturn(Optional.of(flow));
when(fixture.protocolClient.validate(
PROVIDER,
"ST-merge",
SERVICE)).thenReturn(exchange);
when(fixture.route.adapter())
.thenReturn(fixture.adapter);
when(fixture.adapter.authenticate(exchange))
.thenReturn(result);
when(fixture.accountMergeProviderProofService
.completePrimary(
eq(request.getSession(false)),
any(),
eq(result),
any(IdentityLoginContext.class)))
.thenReturn(
new AccountMergeProviderPrimaryProof(
principal(),
new AccountMergePrimaryProof(
"provider:cas-main",
Instant.parse(
"2026-07-31T00:00:00Z"),
Instant.parse(
"2026-07-31T00:10:00Z"))));
assertThat(fixture.service.complete(
PROVIDER,
"ST-merge",
STATE,
request)).isEqualTo("/skills");
verify(fixture.accountMergeProviderProofService)
.completePrimary(
eq(request.getSession(false)),
any(),
eq(result),
any(IdentityLoginContext.class));
verifyNoInteractions(
fixture.providerLogin,
fixture.sessions,
fixture.externalIdentityLinkService);
}
@Test
void completesSecondaryAccountMergeProofWithoutReplacingSession() {
Fixture fixture = new Fixture();
MockHttpServletRequest request = request();
PlatformPrincipal primary = primaryPrincipal();
request.getSession().setAttribute(
"platformPrincipal",
primary);
CasAuthenticationExchange exchange =
new CasAuthenticationExchange(
"user-2",
Map.of(),
Instant.parse(
"2026-07-31T00:00:00Z"));
ProviderAuthenticationResult result = result();
UUID intentId = UUID.randomUUID();
AccountMergeActor actor = new AccountMergeActor(
"usr_primary",
"local",
"account-merge-session-nonce",
"local-password",
Instant.parse("2026-07-31T00:00:00Z"),
IdentityLoginContext.empty());
AccountMergeBrowserFlow.Secondary flow =
new AccountMergeBrowserFlow.Secondary(
intentId,
actor,
PROVIDER);
when(fixture.stateStore.consume(
request.getSession().getId(),
STATE)).thenReturn(consumedState());
when(fixture.accountMergeSessionManager
.consumeBrowserFlow(
eq(request),
eq(PROVIDER),
any(IdentityLoginContext.class)))
.thenReturn(Optional.of(flow));
when(fixture.protocolClient.validate(
PROVIDER,
"ST-merge-secondary",
SERVICE)).thenReturn(exchange);
when(fixture.route.adapter())
.thenReturn(fixture.adapter);
when(fixture.adapter.authenticate(exchange))
.thenReturn(result);
when(fixture.accountMergeProviderProofService
.completeSecondary(
eq(actor),
eq(intentId),
any(),
eq(result),
any(IdentityLoginContext.class)))
.thenReturn(new AccountMergeIntent(
intentId,
AccountMergeIntentStatus.READY_FOR_PREVIEW,
Instant.parse(
"2026-07-31T00:10:00Z")));
assertThat(fixture.service.complete(
PROVIDER,
"ST-merge-secondary",
STATE,
request)).isEqualTo("/skills");
verify(fixture.accountMergeProviderProofService)
.completeSecondary(
eq(actor),
eq(intentId),
any(),
eq(result),
any(IdentityLoginContext.class));
assertThat(request.getSession(false)
.getAttribute("platformPrincipal"))
.isEqualTo(primary);
verifyNoInteractions(
fixture.providerLogin,
fixture.sessions,
fixture.externalIdentityLinkService);
}
@Test
void mapsCasIdentityLinkProviderFailureToResumableRedirect() {
Fixture fixture = new Fixture();
@ -424,6 +584,16 @@ class CasLoginAppServiceTest {
Set.of("USER"));
}
private static PlatformPrincipal primaryPrincipal() {
return new PlatformPrincipal(
"usr_primary",
"Primary",
"primary@example.com",
null,
"local",
Set.of("USER"));
}
private static MockHttpServletRequest request() {
MockHttpServletRequest request =
new MockHttpServletRequest();
@ -445,6 +615,12 @@ class CasLoginAppServiceTest {
private final IdentityLinkSessionManager
identityLinkSessionManager =
mock(IdentityLinkSessionManager.class);
private final AccountMergeProviderProofService
accountMergeProviderProofService =
mock(AccountMergeProviderProofService.class);
private final AccountMergeSessionManager
accountMergeSessionManager =
mock(AccountMergeSessionManager.class);
private final PlatformSessionService sessions =
mock(PlatformSessionService.class);
private final CasLoginStateStore stateStore =
@ -466,6 +642,8 @@ class CasLoginAppServiceTest {
providerLogin,
externalIdentityLinkService,
identityLinkSessionManager,
accountMergeProviderProofService,
accountMergeSessionManager,
sessions,
stateStore,
auditLogService,

View file

@ -20,6 +20,7 @@ import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod;
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import jakarta.servlet.http.HttpSession;
import java.net.URI;
import java.net.URLDecoder;
@ -67,6 +68,8 @@ class IdentityLinkAppServiceTest {
IdentityLinkBrowserPhase.REAUTHENTICATE,
PROVIDER,
fixture.context);
verify(fixture.accountMergeSessionManager)
.clearBrowserFlow(fixture.session);
}
@Test
@ -101,6 +104,8 @@ class IdentityLinkAppServiceTest {
IdentityLinkBrowserPhase.LINK,
PROVIDER,
fixture.context);
verify(fixture.accountMergeSessionManager)
.clearBrowserFlow(fixture.session);
}
@Test
@ -169,6 +174,9 @@ class IdentityLinkAppServiceTest {
mock(IdentityProviderRegistry.class);
private final IdentityLinkSessionManager sessionManager =
mock(IdentityLinkSessionManager.class);
private final AccountMergeSessionManager
accountMergeSessionManager =
mock(AccountMergeSessionManager.class);
private final HttpSession session = mock(HttpSession.class);
private final IdentityLoginContext context =
new IdentityLoginContext(
@ -186,7 +194,8 @@ class IdentityLinkAppServiceTest {
intentService,
externalLinkService,
registry,
sessionManager);
sessionManager,
accountMergeSessionManager);
private Fixture() {
when(sessionManager.actor(

View file

@ -0,0 +1,26 @@
package com.iflytek.skillhub.task;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.merge.AccountMergeIntentService;
import org.junit.jupiter.api.Test;
class AccountMergeIntentCleanupTaskTest {
@Test
void expiresOneBoundedBatch() {
AccountMergeIntentService intentService =
mock(AccountMergeIntentService.class);
when(intentService.expireDueIntents(
AccountMergeIntentCleanupTask.BATCH_SIZE))
.thenReturn(3);
new AccountMergeIntentCleanupTask(
intentService).expireDueIntents();
verify(intentService).expireDueIntents(
AccountMergeIntentCleanupTask.BATCH_SIZE);
}
}

View file

@ -0,0 +1,283 @@
package com.iflytek.skillhub.task;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.merge.AccountMergeMetrics;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.notification.sse.SseEmitterManager;
import com.iflytek.skillhub.repository.AccountMergeSessionRevocationRepository;
import com.iflytek.skillhub.repository.AccountMergeSessionRevocationRepository.Claim;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;
import org.springframework.session.data.redis.RedisIndexedSessionRepository;
import org.springframework.session.data.redis.RedisSessionRepository;
@Tag("redis")
@EnabledIfEnvironmentVariable(named = "REDIS_TEST_HOST", matches = ".+")
class AccountMergeSessionRedisIntegrationTest {
private static final Instant NOW =
Instant.parse("2026-07-31T10:00:00Z");
private static final Clock CLOCK =
Clock.fixed(NOW, ZoneOffset.UTC);
@Test
void principalIndexFindsEverySessionAndRevocationDeletesOnlySecondary() {
try (Fixture fixture = new Fixture()) {
String secondaryFirst = fixture.createSession("usr_secondary");
String secondarySecond = fixture.createSession("usr_secondary");
String primary = fixture.createSession("usr_primary");
assertThat(fixture.sessions.findByPrincipalName("usr_secondary"))
.containsOnlyKeys(secondaryFirst, secondarySecond);
assertThat(fixture.sessions.findByPrincipalName("usr_primary"))
.containsOnlyKeys(primary);
AccountMergeSessionRevocationRepository tasks =
mock(AccountMergeSessionRevocationRepository.class);
SseEmitterManager sse = mock(SseEmitterManager.class);
AccountMergeMetrics metrics = mock(AccountMergeMetrics.class);
AuditLogService auditLogService =
mock(AuditLogService.class);
Claim claim = claim(1);
when(tasks.claimNext(
NOW,
AccountMergeSessionRevocationTask.LEASE_DURATION))
.thenReturn(Optional.of(claim))
.thenReturn(Optional.empty());
when(tasks.complete(claim, NOW)).thenReturn(true);
new AccountMergeSessionRevocationTask(
tasks,
fixture.sessions,
sse,
metrics,
auditLogService,
CLOCK).processDueRevocations();
assertThat(fixture.sessions.findByPrincipalName("usr_secondary"))
.isEmpty();
assertThat(fixture.sessions.findByPrincipalName("usr_primary"))
.containsOnlyKeys(primary);
verify(sse).closeAll("usr_secondary");
verify(tasks).complete(claim, NOW);
verify(metrics).recordSessionRevocation("success");
}
}
@Test
void deletionFailureRetriesAndLaterClearsPrincipalIndex() {
try (Fixture fixture = new Fixture()) {
String secondary = fixture.createSession("usr_secondary");
FindByIndexNameSessionRepository<Session> flakySessions =
spy(fixture.sessions);
doThrow(new IllegalStateException("redis delete failed"))
.doCallRealMethod()
.when(flakySessions)
.deleteById(secondary);
AccountMergeSessionRevocationRepository tasks =
mock(AccountMergeSessionRevocationRepository.class);
SseEmitterManager sse = mock(SseEmitterManager.class);
AccountMergeMetrics metrics = mock(AccountMergeMetrics.class);
AuditLogService auditLogService =
mock(AuditLogService.class);
Claim firstAttempt = claim(1);
Claim secondAttempt = claim(2);
when(tasks.claimNext(
NOW,
AccountMergeSessionRevocationTask.LEASE_DURATION))
.thenReturn(
Optional.of(firstAttempt),
Optional.empty(),
Optional.of(secondAttempt),
Optional.empty());
when(tasks.retry(
firstAttempt,
NOW.plusSeconds(5),
AccountMergeSessionRevocationTask.SESSION_STORE_FAILURE,
NOW)).thenReturn(true);
when(tasks.complete(secondAttempt, NOW)).thenReturn(true);
AccountMergeSessionRevocationTask task =
new AccountMergeSessionRevocationTask(
tasks,
flakySessions,
sse,
metrics,
auditLogService,
CLOCK);
task.processDueRevocations();
assertThat(flakySessions.findByPrincipalName("usr_secondary"))
.containsOnlyKeys(secondary);
verify(tasks).retry(
firstAttempt,
NOW.plusSeconds(5),
AccountMergeSessionRevocationTask.SESSION_STORE_FAILURE,
NOW);
verify(metrics).recordSessionRevocation("retry");
task.processDueRevocations();
assertThat(flakySessions.findByPrincipalName("usr_secondary"))
.isEmpty();
verify(tasks).complete(secondAttempt, NOW);
verify(metrics).recordSessionRevocation("success");
}
}
@Test
void legacyRepositorySessionRemainsUnindexedAfterItIsTouched() {
try (Fixture fixture = new Fixture()) {
RedisSessionRepository legacyRepository =
new RedisSessionRepository(fixture.template);
legacyRepository.setRedisKeyNamespace(fixture.namespace);
SessionRepository<Session> legacySessions =
sessionRepository(legacyRepository);
Session legacySession =
legacySessions.createSession();
fixture.setPrincipal(legacySession, "usr_legacy");
legacySessions.save(legacySession);
assertThat(fixture.sessions.findByPrincipalName("usr_legacy"))
.isEmpty();
Session touched =
fixture.sessions.findById(legacySession.getId());
assertThat(touched).isNotNull();
touched.setLastAccessedTime(NOW);
fixture.sessions.save(touched);
assertThat(fixture.sessions.findByPrincipalName("usr_legacy"))
.isEmpty();
}
}
private static Claim claim(int attemptCount) {
return new Claim(
42L,
"usr_secondary",
attemptCount,
NOW.plus(AccountMergeSessionRevocationTask.LEASE_DURATION));
}
@SuppressWarnings({"rawtypes", "unchecked"})
private static FindByIndexNameSessionRepository<Session>
indexedSessionRepository(
FindByIndexNameSessionRepository repository) {
return (FindByIndexNameSessionRepository<Session>) repository;
}
@SuppressWarnings({"rawtypes", "unchecked"})
private static SessionRepository<Session> sessionRepository(
SessionRepository repository) {
return (SessionRepository<Session>) repository;
}
private static final class Fixture implements AutoCloseable {
private final LettuceConnectionFactory connectionFactory;
private final RedisTemplate<String, Object> template;
private final String namespace =
"skillhub:test:account-merge:" + UUID.randomUUID();
private final RedisIndexedSessionRepository redisSessions;
private final FindByIndexNameSessionRepository<Session> sessions;
private Fixture() {
RedisStandaloneConfiguration configuration =
new RedisStandaloneConfiguration(
System.getenv("REDIS_TEST_HOST"),
Integer.parseInt(
System.getenv().getOrDefault(
"REDIS_TEST_PORT",
"6379")));
String password = System.getenv("REDIS_TEST_PASSWORD");
if (password != null && !password.isBlank()) {
configuration.setPassword(RedisPassword.of(password));
}
configuration.setDatabase(Integer.parseInt(
System.getenv().getOrDefault(
"REDIS_TEST_DATABASE",
"0")));
connectionFactory =
new LettuceConnectionFactory(configuration);
connectionFactory.afterPropertiesSet();
template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
redisSessions = new RedisIndexedSessionRepository(template);
redisSessions.setRedisKeyNamespace(namespace);
redisSessions.afterPropertiesSet();
sessions = indexedSessionRepository(redisSessions);
}
private String createSession(String userId) {
Session session =
sessions.createSession();
setPrincipal(session, userId);
sessions.save(session);
return session.getId();
}
private void setPrincipal(Session session, String userId) {
PlatformPrincipal principal = new PlatformPrincipal(
userId,
userId,
userId + "@example.com",
null,
"local",
Set.of("USER"));
session.setAttribute(
HttpSessionSecurityContextRepository
.SPRING_SECURITY_CONTEXT_KEY,
new SecurityContextImpl(
new UsernamePasswordAuthenticationToken(
principal,
null,
List.of())));
}
@Override
public void close() {
Set<String> keys = template.keys(namespace + "*");
if (keys != null && !keys.isEmpty()) {
template.delete(keys);
}
redisSessions.destroy();
connectionFactory.destroy();
}
}
}

View file

@ -0,0 +1,194 @@
package com.iflytek.skillhub.task;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.merge.AccountMergeMetrics;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.notification.sse.SseEmitterManager;
import com.iflytek.skillhub.repository.AccountMergeSessionRevocationRepository;
import com.iflytek.skillhub.repository.AccountMergeSessionRevocationRepository.Claim;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.Session;
class AccountMergeSessionRevocationTaskTest {
private static final Instant NOW =
Instant.parse("2026-07-31T10:00:00Z");
private static final Clock CLOCK = Clock.fixed(
NOW,
ZoneOffset.UTC);
@Test
void closesSseDeletesEveryIndexedSessionAndCompletesTask() {
Fixture fixture = new Fixture();
Claim claim = claim(1);
Session first = mock(Session.class);
Session second = mock(Session.class);
when(fixture.repository.claimNext(
NOW,
AccountMergeSessionRevocationTask
.LEASE_DURATION))
.thenReturn(Optional.of(claim))
.thenReturn(Optional.empty());
when(fixture.sessions.findByPrincipalName("usr_secondary"))
.thenReturn(Map.of(
"session-1",
first,
"session-2",
second))
.thenReturn(Map.of());
when(fixture.repository.complete(claim, NOW))
.thenReturn(true);
fixture.task.processDueRevocations();
InOrder order = inOrder(
fixture.sse,
fixture.sessions,
fixture.repository);
order.verify(fixture.sse).closeAll("usr_secondary");
order.verify(fixture.sessions)
.findByPrincipalName("usr_secondary");
verify(fixture.sessions).deleteById("session-1");
verify(fixture.sessions).deleteById("session-2");
order.verify(fixture.sessions)
.findByPrincipalName("usr_secondary");
order.verify(fixture.repository).complete(claim, NOW);
verify(fixture.metrics)
.recordSessionRevocation("success");
}
@Test
void retriesWithBoundedBackoffWhenRedisDeletionFails() {
Fixture fixture = new Fixture();
Claim claim = claim(3);
when(fixture.repository.claimNext(
NOW,
AccountMergeSessionRevocationTask
.LEASE_DURATION))
.thenReturn(Optional.of(claim))
.thenReturn(Optional.empty());
when(fixture.sessions.findByPrincipalName("usr_secondary"))
.thenThrow(new IllegalStateException(
"redis unavailable"));
when(fixture.repository.retry(
claim,
NOW.plusSeconds(20),
AccountMergeSessionRevocationTask
.SESSION_STORE_FAILURE,
NOW)).thenReturn(true);
fixture.task.processDueRevocations();
verify(fixture.repository).retry(
claim,
NOW.plusSeconds(20),
AccountMergeSessionRevocationTask
.SESSION_STORE_FAILURE,
NOW);
verify(fixture.metrics)
.recordSessionRevocation("retry");
verify(fixture.auditLogService).record(
"usr_secondary",
"ACCOUNT_MERGE_SESSION_REVOCATION_RETRIED",
"ACCOUNT_MERGE_SESSION_REVOCATION",
42L,
null,
null,
null,
"{\"attempt\":3,\"reason\":"
+ "\"SESSION_STORE_FAILURE\"}");
}
@Test
void retriesWhenAnIndexedSessionRemainsAfterDeletion() {
Fixture fixture = new Fixture();
Claim claim = claim(2);
Session session = mock(Session.class);
when(fixture.repository.claimNext(
NOW,
AccountMergeSessionRevocationTask
.LEASE_DURATION))
.thenReturn(Optional.of(claim))
.thenReturn(Optional.empty());
when(fixture.sessions.findByPrincipalName("usr_secondary"))
.thenReturn(Map.of("session-1", session))
.thenReturn(Map.of("session-1", session));
when(fixture.repository.retry(
claim,
NOW.plusSeconds(10),
AccountMergeSessionRevocationTask
.SESSION_DELETE_INCOMPLETE,
NOW)).thenReturn(true);
fixture.task.processDueRevocations();
verify(fixture.sessions).deleteById("session-1");
verify(fixture.repository).retry(
claim,
NOW.plusSeconds(10),
AccountMergeSessionRevocationTask
.SESSION_DELETE_INCOMPLETE,
NOW);
verify(fixture.metrics)
.recordSessionRevocation("retry");
}
@Test
void backoffIsExponentialAndCapped() {
assertThat(AccountMergeSessionRevocationTask
.backoff(1)).isEqualTo(Duration.ofSeconds(5));
assertThat(AccountMergeSessionRevocationTask
.backoff(4)).isEqualTo(Duration.ofSeconds(40));
assertThat(AccountMergeSessionRevocationTask
.backoff(100)).isEqualTo(
Duration.ofSeconds(300));
}
private static Claim claim(int attemptCount) {
return new Claim(
42L,
"usr_secondary",
attemptCount,
NOW.plus(
AccountMergeSessionRevocationTask
.LEASE_DURATION));
}
private static final class Fixture {
private final AccountMergeSessionRevocationRepository
repository = mock(
AccountMergeSessionRevocationRepository.class);
@SuppressWarnings("unchecked")
private final FindByIndexNameSessionRepository<Session>
sessions = mock(
FindByIndexNameSessionRepository.class);
private final SseEmitterManager sse =
mock(SseEmitterManager.class);
private final AccountMergeMetrics metrics =
mock(AccountMergeMetrics.class);
private final AuditLogService auditLogService =
mock(AuditLogService.class);
private final AccountMergeSessionRevocationTask task =
new AccountMergeSessionRevocationTask(
repository,
sessions,
sse,
metrics,
auditLogService,
CLOCK);
}
}

View file

@ -0,0 +1,27 @@
package com.iflytek.skillhub.auth.config;
import jakarta.servlet.http.HttpServletRequest;
/**
* Identifies the new safe account-merge API surface.
*/
public final class AccountMergeRouteRequestMatcher {
private static final String PATH_PREFIX =
"/api/v1/account/merge";
private AccountMergeRouteRequestMatcher() {
}
public static boolean matches(
HttpServletRequest request) {
String path = request.getRequestURI();
return path != null
&& (path.equals(PATH_PREFIX + "/capabilities")
|| path.equals(PATH_PREFIX + "/intents")
|| path.startsWith(
PATH_PREFIX + "/intents/")
|| path.startsWith(
PATH_PREFIX + "/reauthenticate/"));
}
}

View file

@ -0,0 +1,48 @@
package com.iflytek.skillhub.auth.identity;
import java.util.Objects;
import org.springframework.stereotype.Service;
/**
* Provider-bound implementation of {@link ExternalIdentityProofService}.
*/
@Service
class DefaultExternalIdentityProofService
implements ExternalIdentityProofService {
private final TrustedProviderDescriptorSource descriptorSource;
private final ProviderAuthorityLockService authorityLockService;
private final IdentityAssertionFactory assertionFactory;
private final IdentityResolutionTransaction resolutionTransaction;
DefaultExternalIdentityProofService(
TrustedProviderDescriptorSource descriptorSource,
ProviderAuthorityLockService authorityLockService,
IdentityAssertionFactory assertionFactory,
IdentityResolutionTransaction resolutionTransaction) {
this.descriptorSource = descriptorSource;
this.authorityLockService = authorityLockService;
this.assertionFactory = assertionFactory;
this.resolutionTransaction = resolutionTransaction;
}
@Override
public ExternalIdentityProof authenticateExisting(
ResolvedProviderHandle provider,
ProviderAuthenticationResult result,
IdentityLoginContext context) {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(result, "result");
Objects.requireNonNull(context, "context");
ProviderDescriptor descriptor =
descriptorSource.require(provider);
authorityLockService.requirePinnedAuthority(descriptor);
IdentityAssertion assertion =
assertionFactory.create(descriptor, result);
return resolutionTransaction
.resolveExistingProof(
assertion,
descriptor,
context);
}
}

View file

@ -0,0 +1,43 @@
package com.iflytek.skillhub.auth.identity;
import java.time.Instant;
import java.util.Objects;
/**
* Existing-account ownership established by one verified provider exchange.
*
* <p>The proof contains no provider subject, credential, token, callback
* state, or protocol artifact.
*/
public record ExternalIdentityProof(
String userId,
String providerCode,
String protocol,
Instant authenticatedAt
) {
public ExternalIdentityProof {
userId = requireText(userId, "userId", 128);
providerCode = requireText(
providerCode,
"providerCode",
64);
protocol = requireText(protocol, "protocol", 32);
Objects.requireNonNull(
authenticatedAt,
"authenticatedAt");
}
private static String requireText(
String value,
String fieldName,
int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength) {
throw new IllegalArgumentException(
"Invalid external identity proof "
+ fieldName);
}
return value;
}
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.auth.identity;
/**
* Resolves a verified provider exchange to an existing eligible account
* without provisioning a new account or synchronizing profile fields.
*/
public interface ExternalIdentityProofService {
ExternalIdentityProof authenticateExisting(
ResolvedProviderHandle provider,
ProviderAuthenticationResult result,
IdentityLoginContext context);
}

View file

@ -123,6 +123,70 @@ class IdentityResolutionTransaction {
context);
}
/**
* Resolves one verified provider exchange to an existing ACTIVE account.
* Unlike interactive login, this path never provisions an account and
* never synchronizes profile fields.
*/
@Transactional
public ExternalIdentityProof resolveExistingProof(
IdentityAssertion assertion,
ProviderDescriptor descriptor,
IdentityLoginContext context) {
ExternalSubject legacySubject =
assertion.requireUniqueSubject(
descriptor.legacyPrimarySubjectType());
MatchResolution initialMatches =
resolveMatches(assertion, legacySubject);
if (initialMatches.bindingId() == null) {
throw accessDenied();
}
IdentityBinding binding = bindingRepository
.findByIdAndStatusForUpdate(
initialMatches.bindingId(),
IdentityBindingStatus.ACTIVE)
.orElseThrow(this::identifierConflict);
MatchResolution lockedMatches =
resolveMatches(assertion, legacySubject);
if (!binding.getId().equals(
lockedMatches.bindingId())
|| !binding.getProviderCode().equals(
assertion.provider().providerCode())
|| !binding.getSubject().equals(
legacySubject.value())) {
throw identifierConflict();
}
UserAccount user = userRepository
.findByIdForUpdate(binding.getUserId())
.orElseThrow(() ->
new IllegalStateException(
"User not found for identity binding"));
requireAllowed(accountLoginGuard.evaluateInteractive(user));
requireAccessAllowed(
assertion,
context,
IdentityAccessKind.RETURNING_IDENTITY,
Optional.of(user.getStatus()));
binding.recordAuthentication(
assertion.evidence().authenticatedAt());
bindingRepository.save(binding);
recordAudit(
user.getId(),
"IDENTITY_REAUTHENTICATION_SUCCEEDED",
binding.getId(),
assertion.provider().providerCode(),
"authenticated",
context);
return new ExternalIdentityProof(
user.getId(),
assertion.provider().providerCode(),
assertion.provider().protocol(),
assertion.evidence().authenticatedAt());
}
private MatchResolution resolveMatches(
IdentityAssertion assertion,
ExternalSubject legacySubject) {

View file

@ -0,0 +1,94 @@
package com.iflytek.skillhub.auth.merge;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import java.time.Instant;
import java.util.Objects;
/**
* Server-owned primary-account and session proof for an account-merge intent.
*
* <p>The raw session nonce is intentionally omitted from {@link #toString()}.
*/
public final class AccountMergeActor {
private final String userId;
private final String authenticationProvider;
private final String sessionNonce;
private final String primaryProofMethod;
private final Instant primaryProofAt;
private final IdentityLoginContext auditContext;
public AccountMergeActor(
String userId,
String authenticationProvider,
String sessionNonce,
String primaryProofMethod,
Instant primaryProofAt,
IdentityLoginContext auditContext) {
this.userId = requireText(userId, "userId", 128);
this.authenticationProvider = requireText(
authenticationProvider,
"authenticationProvider",
64);
this.sessionNonce = requireText(
sessionNonce,
"sessionNonce",
256);
this.primaryProofMethod = requireText(
primaryProofMethod,
"primaryProofMethod",
96);
this.primaryProofAt = Objects.requireNonNull(
primaryProofAt,
"primaryProofAt");
this.auditContext = Objects.requireNonNull(
auditContext,
"auditContext");
}
public String userId() {
return userId;
}
String authenticationProvider() {
return authenticationProvider;
}
String sessionNonce() {
return sessionNonce;
}
public String primaryProofMethod() {
return primaryProofMethod;
}
public Instant primaryProofAt() {
return primaryProofAt;
}
public IdentityLoginContext auditContext() {
return auditContext;
}
@Override
public String toString() {
return "AccountMergeActor[userId="
+ userId
+ ", authenticationProvider="
+ authenticationProvider
+ "]";
}
private static String requireText(
String value,
String fieldName,
int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength) {
throw new IllegalArgumentException(
"Invalid account merge actor " + fieldName);
}
return value;
}
}

View file

@ -0,0 +1,66 @@
package com.iflytek.skillhub.auth.merge;
import java.util.Objects;
import java.util.UUID;
/**
* One-time, session-bound browser provider flow.
*/
public sealed interface AccountMergeBrowserFlow
permits AccountMergeBrowserFlow.Primary,
AccountMergeBrowserFlow.Secondary {
String primaryUserId();
String providerCode();
record Primary(
String primaryUserId,
String providerCode
) implements AccountMergeBrowserFlow {
public Primary {
primaryUserId = requireText(
primaryUserId,
"primaryUserId",
128);
providerCode = requireText(
providerCode,
"providerCode",
64);
}
}
record Secondary(
UUID intentId,
AccountMergeActor actor,
String providerCode
) implements AccountMergeBrowserFlow {
public Secondary {
Objects.requireNonNull(intentId, "intentId");
Objects.requireNonNull(actor, "actor");
providerCode = requireText(
providerCode,
"providerCode",
64);
}
@Override
public String primaryUserId() {
return actor.userId();
}
}
private static String requireText(
String value,
String fieldName,
int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength) {
throw new IllegalArgumentException(
"Invalid account merge browser flow "
+ fieldName);
}
return value;
}
}

View file

@ -0,0 +1,12 @@
package com.iflytek.skillhub.auth.merge;
import java.util.UUID;
/**
* Non-sensitive redirect metadata retained when a browser provider fails.
*/
public record AccountMergeBrowserFlowReference(
AccountMergeBrowserPhase phase,
UUID intentId
) {
}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.auth.merge;
/**
* Ownership proof being completed by a browser identity provider.
*/
public enum AccountMergeBrowserPhase {
PRIMARY_REAUTHENTICATION,
SECONDARY_AUTHENTICATION
}

View file

@ -0,0 +1,23 @@
package com.iflytek.skillhub.auth.merge;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/**
* Non-secret completion receipt for one consumed merge intent.
*/
public record AccountMergeCompletion(
UUID intentId,
AccountMergeIntentStatus status,
Instant completedAt
) {
public AccountMergeCompletion {
Objects.requireNonNull(intentId, "intentId");
if (status != AccountMergeIntentStatus.COMPLETED) {
throw new IllegalArgumentException(
"Account merge completion must be completed");
}
Objects.requireNonNull(completedAt, "completedAt");
}
}

View file

@ -0,0 +1,27 @@
package com.iflytek.skillhub.auth.merge;
import java.time.Instant;
import java.util.UUID;
/**
* Cross-aggregate persistence boundary for account-merge planning and
* migration.
*
* <p>The implementation lives in {@code skillhub-app}, which can see every
* participating module. Both methods run inside the caller's PostgreSQL
* transaction.
*/
public interface AccountMergeDataGateway {
AccountMergePlan inspect(
String primaryUserId,
String secondaryUserId,
Instant now);
void apply(
String primaryUserId,
String secondaryUserId,
UUID intentId,
AccountMergePlan plan,
Instant now);
}

View file

@ -0,0 +1,30 @@
package com.iflytek.skillhub.auth.merge;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
/**
* Account-merge failure carrying a stable reason code.
*/
public final class AccountMergeException extends AuthFlowException {
private final AccountMergeFailureCode reasonCode;
public AccountMergeException(
AccountMergeFailureCode reasonCode) {
this(reasonCode, null);
}
public AccountMergeException(
AccountMergeFailureCode reasonCode,
Throwable cause) {
super(reasonCode.status(), reasonCode.messageCode());
this.reasonCode = reasonCode;
if (cause != null) {
initCause(cause);
}
}
public AccountMergeFailureCode getReasonCode() {
return reasonCode;
}
}

View file

@ -0,0 +1,60 @@
package com.iflytek.skillhub.auth.merge;
import org.springframework.http.HttpStatus;
/**
* Stable machine-readable failures for the safe account-merge workflow.
*/
public enum AccountMergeFailureCode {
ACCOUNT_MERGE_UNAVAILABLE(
HttpStatus.SERVICE_UNAVAILABLE,
"error.auth.accountMerge.unavailable"),
MERGE_INTENT_NOT_FOUND(
HttpStatus.NOT_FOUND,
"error.auth.accountMerge.intentNotFound"),
MERGE_REAUTH_REQUIRED(
HttpStatus.UNAUTHORIZED,
"error.auth.accountMerge.reauthenticationRequired"),
MERGE_PROVIDER_AUTHENTICATION_FAILED(
HttpStatus.UNAUTHORIZED,
"error.auth.accountMerge.providerAuthenticationFailed"),
MERGE_PROVIDER_UNAVAILABLE(
HttpStatus.SERVICE_UNAVAILABLE,
"error.auth.accountMerge.providerUnavailable"),
MERGE_SESSION_MISMATCH(
HttpStatus.FORBIDDEN,
"error.auth.accountMerge.sessionMismatch"),
MERGE_PROOF_EXPIRED(
HttpStatus.GONE,
"error.auth.accountMerge.proofExpired"),
MERGE_CONFLICT(
HttpStatus.CONFLICT,
"error.auth.accountMerge.conflict"),
MERGE_PREVIEW_STALE(
HttpStatus.CONFLICT,
"error.auth.accountMerge.previewStale"),
MERGE_ALREADY_CONSUMED(
HttpStatus.CONFLICT,
"error.auth.accountMerge.alreadyConsumed"),
MERGE_ACCOUNT_NOT_ELIGIBLE(
HttpStatus.CONFLICT,
"error.auth.accountMerge.accountNotEligible");
private final HttpStatus status;
private final String messageCode;
AccountMergeFailureCode(
HttpStatus status,
String messageCode) {
this.status = status;
this.messageCode = messageCode;
}
public HttpStatus status() {
return status;
}
public String messageCode() {
return messageCode;
}
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.auth.merge;
import java.time.Instant;
import java.util.UUID;
/**
* Non-sensitive public projection of an account-merge intent.
*/
public record AccountMergeIntent(
UUID id,
AccountMergeIntentStatus status,
Instant expiresAt
) {
}

View file

@ -0,0 +1,342 @@
package com.iflytek.skillhub.auth.merge;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* Persisted metadata for a server-side account-merge intent.
*
* <p>Raw proofs, passwords, provider tokens, and Session identifiers are never
* persisted in this entity.
*/
@Entity
@Table(name = "account_merge_intent")
public class AccountMergeIntentEntity {
private static final Pattern SHA256_PATTERN =
Pattern.compile("[0-9a-f]{64}");
@Id
private UUID id;
@Column(
name = "primary_user_id",
nullable = false,
length = 128)
private String primaryUserId;
@Column(name = "secondary_user_id", length = 128)
private String secondaryUserId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private AccountMergeIntentStatus status;
@Column(
name = "primary_session_nonce_hash",
nullable = false,
length = 64)
private String primarySessionNonceHash;
@Column(
name = "primary_proof_method",
nullable = false,
length = 96)
private String primaryProofMethod;
@Column(name = "primary_proof_at", nullable = false)
private Instant primaryProofAt;
@Column(name = "secondary_proof_method", length = 96)
private String secondaryProofMethod;
@Column(name = "secondary_proof_at")
private Instant secondaryProofAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "preview_version")
private Integer previewVersion;
@Column(name = "preview_digest", length = 64)
private String previewDigest;
@Column(name = "confirmed_at")
private Instant confirmedAt;
@Column(name = "completed_at")
private Instant completedAt;
@Column(name = "cancelled_at")
private Instant cancelledAt;
@Version
@Column(name = "row_version", nullable = false)
private long rowVersion;
@Column(
name = "created_at",
nullable = false,
updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected AccountMergeIntentEntity() {
}
public AccountMergeIntentEntity(
UUID id,
String primaryUserId,
String primarySessionNonceHash,
String primaryProofMethod,
Instant primaryProofAt,
Instant expiresAt,
Instant createdAt) {
this.id = Objects.requireNonNull(id, "id");
this.primaryUserId = requireText(
primaryUserId,
"primaryUserId",
128);
this.primarySessionNonceHash = requireHash(
primarySessionNonceHash,
"primarySessionNonceHash");
this.primaryProofMethod = requireText(
primaryProofMethod,
"primaryProofMethod",
96);
this.primaryProofAt = Objects.requireNonNull(
primaryProofAt,
"primaryProofAt");
this.expiresAt = Objects.requireNonNull(
expiresAt,
"expiresAt");
this.createdAt = Objects.requireNonNull(
createdAt,
"createdAt");
if (primaryProofAt.isAfter(createdAt)
|| !expiresAt.isAfter(createdAt)) {
throw new IllegalArgumentException(
"Invalid account merge intent time range");
}
this.status =
AccountMergeIntentStatus
.PENDING_SECONDARY_PROOF;
this.updatedAt = createdAt;
}
public UUID getId() {
return id;
}
public String getPrimaryUserId() {
return primaryUserId;
}
public String getSecondaryUserId() {
return secondaryUserId;
}
public AccountMergeIntentStatus getStatus() {
return status;
}
public String getPrimarySessionNonceHash() {
return primarySessionNonceHash;
}
public String getPrimaryProofMethod() {
return primaryProofMethod;
}
public Instant getPrimaryProofAt() {
return primaryProofAt;
}
public String getSecondaryProofMethod() {
return secondaryProofMethod;
}
public Instant getSecondaryProofAt() {
return secondaryProofAt;
}
public Instant getExpiresAt() {
return expiresAt;
}
public Integer getPreviewVersion() {
return previewVersion;
}
public String getPreviewDigest() {
return previewDigest;
}
public Instant getConfirmedAt() {
return confirmedAt;
}
public Instant getCompletedAt() {
return completedAt;
}
public Instant getCancelledAt() {
return cancelledAt;
}
public long getRowVersion() {
return rowVersion;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public boolean isExpiredAt(Instant now) {
return !Objects.requireNonNull(now, "now")
.isBefore(expiresAt);
}
public void expire(Instant now) {
if (!status.isActive()) {
throw new IllegalStateException(
"Only an active account merge intent can expire");
}
status = AccountMergeIntentStatus.EXPIRED;
updatedAt = Objects.requireNonNull(now, "now");
}
public void recordSecondaryProof(
String userId,
String method,
Instant now) {
if (status
!= AccountMergeIntentStatus
.PENDING_SECONDARY_PROOF) {
throw new IllegalStateException(
"Account merge intent is not waiting"
+ " for a secondary proof");
}
String requiredUserId = requireText(
userId,
"secondaryUserId",
128);
if (requiredUserId.equals(primaryUserId)) {
throw new IllegalArgumentException(
"Account merge users must be distinct");
}
secondaryUserId = requiredUserId;
secondaryProofMethod = requireText(
method,
"secondaryProofMethod",
96);
secondaryProofAt = Objects.requireNonNull(now, "now");
status =
AccountMergeIntentStatus
.READY_FOR_PREVIEW;
updatedAt = now;
}
public int recordPreview(
String digest,
boolean confirmable,
Instant now) {
if (status != AccountMergeIntentStatus.READY_FOR_PREVIEW
&& status
!= AccountMergeIntentStatus.READY_TO_CONFIRM
&& status
!= AccountMergeIntentStatus.FAILED_CONFLICT) {
throw new IllegalStateException(
"Account merge intent is not ready"
+ " for a preview");
}
previewVersion = previewVersion == null
? 1
: Math.addExact(previewVersion, 1);
previewDigest = requireHash(
digest,
"previewDigest");
status = confirmable
? AccountMergeIntentStatus.READY_TO_CONFIRM
: AccountMergeIntentStatus.FAILED_CONFLICT;
updatedAt = Objects.requireNonNull(now, "now");
return previewVersion;
}
public void markPreviewStale(Instant now) {
if (status
!= AccountMergeIntentStatus.READY_TO_CONFIRM) {
throw new IllegalStateException(
"Account merge intent has no confirmable preview");
}
status = AccountMergeIntentStatus.READY_FOR_PREVIEW;
previewVersion = null;
previewDigest = null;
confirmedAt = null;
updatedAt = Objects.requireNonNull(now, "now");
}
public void complete(Instant now) {
if (status
!= AccountMergeIntentStatus.READY_TO_CONFIRM) {
throw new IllegalStateException(
"Account merge intent is not ready"
+ " to complete");
}
confirmedAt = Objects.requireNonNull(now, "now");
completedAt = now;
status = AccountMergeIntentStatus.COMPLETED;
updatedAt = now;
}
public void cancel(Instant now) {
if (!status.isActive()) {
throw new IllegalStateException(
"Only an active account merge intent"
+ " can be cancelled");
}
cancelledAt = Objects.requireNonNull(now, "now");
status = AccountMergeIntentStatus.CANCELLED;
updatedAt = now;
}
private static String requireHash(
String value,
String fieldName) {
if (value == null
|| !SHA256_PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException(
"Invalid account merge " + fieldName);
}
return value;
}
private static String requireText(
String value,
String fieldName,
int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength) {
throw new IllegalArgumentException(
"Invalid account merge " + fieldName);
}
return value;
}
}

View file

@ -0,0 +1,60 @@
package com.iflytek.skillhub.auth.merge;
import jakarta.persistence.LockModeType;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface AccountMergeIntentRepository
extends JpaRepository<AccountMergeIntentEntity, UUID> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select intent
from AccountMergeIntentEntity intent
where intent.id = :intentId
""")
Optional<AccountMergeIntentEntity> findByIdForUpdate(
@Param("intentId") UUID intentId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select intent
from AccountMergeIntentEntity intent
where (
intent.primaryUserId = :userId
or intent.secondaryUserId = :userId
)
and intent.status in :statuses
""")
List<AccountMergeIntentEntity>
findActiveByParticipantForUpdate(
@Param("userId") String userId,
@Param("statuses")
Collection<AccountMergeIntentStatus>
statuses);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select intent
from AccountMergeIntentEntity intent
where intent.status in :statuses
and intent.expiresAt <= :now
order by intent.expiresAt, intent.id
""")
List<AccountMergeIntentEntity> findExpiredForUpdate(
@Param("now") Instant now,
@Param("statuses")
Collection<AccountMergeIntentStatus>
statuses,
Pageable pageable);
}

View file

@ -0,0 +1,233 @@
package com.iflytek.skillhub.auth.merge;
import java.sql.SQLException;
import java.util.Objects;
import java.util.UUID;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
/**
* Public facade for safe account-merge intent state.
*/
@Service
public class AccountMergeIntentService {
private final AccountMergeProperties properties;
private final AccountMergeIntentTransaction transaction;
private final AccountMergeMetrics metrics;
public AccountMergeIntentService(
AccountMergeProperties properties,
AccountMergeIntentTransaction transaction,
AccountMergeMetrics metrics) {
this.properties = properties;
this.transaction = transaction;
this.metrics = metrics;
}
public void requireAvailable() {
if (!properties.isEnabled()) {
throw new AccountMergeException(
AccountMergeFailureCode
.ACCOUNT_MERGE_UNAVAILABLE);
}
}
public boolean isAvailable() {
return properties.isEnabled();
}
public AccountMergeIntent createIntent(
AccountMergeActor actor,
UUID intentId) {
requireAvailable();
Objects.requireNonNull(actor, "actor");
Objects.requireNonNull(intentId, "intentId");
try {
AccountMergeIntent intent =
transaction.createIntent(actor, intentId);
metrics.record("intent", "created");
return intent;
} catch (DataIntegrityViolationException exception) {
if (isUniqueConstraintViolation(exception)) {
throw new AccountMergeException(
AccountMergeFailureCode.MERGE_CONFLICT,
exception);
}
throw exception;
}
}
public AccountMergeIntent getIntent(
AccountMergeActor actor,
UUID intentId) {
requireAvailable();
return transaction.getIntent(actor, intentId);
}
public AccountMergeIntent recordSecondaryProof(
AccountMergeActor actor,
UUID intentId,
String secondaryUserId,
String method) {
requireAvailable();
Objects.requireNonNull(
secondaryUserId,
"secondaryUserId");
try {
AccountMergeIntent intent =
transaction.recordSecondaryProof(
actor,
intentId,
secondaryUserId,
method);
metrics.record("proof", "secondary_success");
return intent;
} catch (DataIntegrityViolationException exception) {
if (isUniqueConstraintViolation(exception)) {
throw new AccountMergeException(
AccountMergeFailureCode.MERGE_CONFLICT,
exception);
}
throw exception;
}
}
public AccountMergePreview preview(
AccountMergeActor actor,
UUID intentId) {
requireAvailable();
AccountMergePreview preview =
transaction.preview(actor, intentId);
metrics.record(
"preview",
preview.plan().confirmable()
? "ready"
: "conflict");
return preview;
}
public AccountMergeCompletion confirm(
AccountMergeActor actor,
UUID intentId,
int previewVersion) {
requireAvailable();
if (previewVersion <= 0) {
throw new AccountMergeException(
AccountMergeFailureCode
.MERGE_PREVIEW_STALE);
}
try {
AccountMergeCompletion completion =
confirmWithSerializationRetry(
actor,
intentId,
previewVersion);
metrics.record("confirm", "completed");
return completion;
} catch (DataIntegrityViolationException exception) {
metrics.record("confirm", "conflict");
throw new AccountMergeException(
AccountMergeFailureCode.MERGE_CONFLICT,
exception);
} catch (AccountMergeException exception) {
metrics.record(
"confirm",
exception.getReasonCode().name());
throw exception;
} catch (RuntimeException exception) {
metrics.record("confirm", "rollback");
throw exception;
}
}
private AccountMergeCompletion
confirmWithSerializationRetry(
AccountMergeActor actor,
UUID intentId,
int previewVersion) {
try {
return transaction.confirm(
actor,
intentId,
previewVersion);
} catch (RuntimeException firstFailure) {
if (!isSerializationFailure(firstFailure)) {
throw firstFailure;
}
metrics.record(
"confirm",
"serialization_retry");
try {
return transaction.confirm(
actor,
intentId,
previewVersion);
} catch (RuntimeException repeatedFailure) {
if (!isSerializationFailure(
repeatedFailure)) {
throw repeatedFailure;
}
throw new AccountMergeException(
AccountMergeFailureCode.MERGE_CONFLICT,
repeatedFailure);
}
}
}
private boolean isSerializationFailure(Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof SQLException sqlException
&& "40001".equals(
sqlException.getSQLState())) {
return true;
}
Throwable cause = current.getCause();
if (cause == current) {
break;
}
current = cause;
}
return false;
}
public AccountMergeIntent cancel(
AccountMergeActor actor,
UUID intentId) {
requireAvailable();
AccountMergeIntent intent =
transaction.cancel(actor, intentId);
metrics.record("intent", "cancelled");
return intent;
}
/**
* Expires persisted intents even when the feature flag is disabled, so
* rollback does not leave active security workflow state indefinitely.
*/
public int expireDueIntents(int batchSize) {
int expired = transaction.expireDueIntents(batchSize);
for (int index = 0; index < expired; index++) {
metrics.record("intent", "expired");
}
return expired;
}
private boolean isUniqueConstraintViolation(Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof SQLException sqlException
&& "23505".equals(
sqlException.getSQLState())) {
return true;
}
Throwable cause = current.getCause();
if (cause == current) {
break;
}
current = cause;
}
return false;
}
}

View file

@ -0,0 +1,21 @@
package com.iflytek.skillhub.auth.merge;
/**
* Server-side state of a safe account-merge intent.
*/
public enum AccountMergeIntentStatus {
PENDING_SECONDARY_PROOF,
READY_FOR_PREVIEW,
READY_TO_CONFIRM,
COMPLETED,
CANCELLED,
EXPIRED,
FAILED_CONFLICT;
public boolean isActive() {
return this == PENDING_SECONDARY_PROOF
|| this == READY_FOR_PREVIEW
|| this == READY_TO_CONFIRM
|| this == FAILED_CONFLICT;
}
}

View file

@ -0,0 +1,498 @@
package com.iflytek.skillhub.auth.merge;
import com.iflytek.skillhub.auth.identity.AccountLoginDecision;
import com.iflytek.skillhub.auth.identity.AccountLoginGuard;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.data.domain.PageRequest;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
/**
* Short PostgreSQL transaction for creating account-merge intents.
*/
@Service
class AccountMergeIntentTransaction {
static final Duration INTENT_TTL = Duration.ofMinutes(10);
private static final Set<AccountMergeIntentStatus>
ACTIVE_STATUSES = Set.of(
AccountMergeIntentStatus
.PENDING_SECONDARY_PROOF,
AccountMergeIntentStatus
.READY_FOR_PREVIEW,
AccountMergeIntentStatus
.READY_TO_CONFIRM,
AccountMergeIntentStatus
.FAILED_CONFLICT);
private final AccountMergeIntentRepository intentRepository;
private final UserAccountRepository userRepository;
private final AccountLoginGuard accountLoginGuard;
private final AccountMergeStateHasher stateHasher;
private final AccountMergeDataGateway dataGateway;
private final AuditLogService auditLogService;
private final Clock clock;
AccountMergeIntentTransaction(
AccountMergeIntentRepository intentRepository,
UserAccountRepository userRepository,
AccountLoginGuard accountLoginGuard,
AccountMergeStateHasher stateHasher,
AccountMergeDataGateway dataGateway,
AuditLogService auditLogService,
Clock clock) {
this.intentRepository = intentRepository;
this.userRepository = userRepository;
this.accountLoginGuard = accountLoginGuard;
this.stateHasher = stateHasher;
this.dataGateway = dataGateway;
this.auditLogService = auditLogService;
this.clock = clock;
}
@Transactional(noRollbackFor = AccountMergeException.class)
public AccountMergeIntent createIntent(
AccountMergeActor actor,
UUID intentId) {
Instant now = now();
requireEligiblePrimary(actor.userId());
if (!now.isBefore(actor.primaryProofAt()
.plus(AccountMergeSessionManager
.PRIMARY_PROOF_TTL))) {
throw failure(
AccountMergeFailureCode
.MERGE_PROOF_EXPIRED);
}
expireExistingIntent(actor, now);
AccountMergeIntentEntity intent =
new AccountMergeIntentEntity(
intentId,
actor.userId(),
stateHasher.hash(
actor.sessionNonce()),
actor.primaryProofMethod(),
actor.primaryProofAt(),
earliest(
now.plus(INTENT_TTL),
actor.primaryProofAt().plus(
AccountMergeSessionManager
.PRIMARY_PROOF_TTL)),
now);
intentRepository.saveAndFlush(intent);
recordAudit(
actor,
"ACCOUNT_MERGE_PRIMARY_REAUTHENTICATED",
intent,
actor.primaryProofMethod());
recordAudit(
actor,
"ACCOUNT_MERGE_INTENT_CREATED",
intent,
"pending_secondary_proof");
return toIntent(intent);
}
@Transactional(noRollbackFor = AccountMergeException.class)
public AccountMergeIntent getIntent(
AccountMergeActor actor,
UUID intentId) {
return toIntent(requireActiveIntent(
actor,
intentId));
}
@Transactional(noRollbackFor = AccountMergeException.class)
public AccountMergeIntent recordSecondaryProof(
AccountMergeActor actor,
UUID intentId,
String secondaryUserId,
String method) {
AccountMergeIntentEntity intent =
requireActiveIntent(actor, intentId);
if (intent.getStatus()
!= AccountMergeIntentStatus
.PENDING_SECONDARY_PROOF) {
throw failure(
AccountMergeFailureCode
.MERGE_ALREADY_CONSUMED);
}
if (actor.userId().equals(secondaryUserId)) {
throw failure(
AccountMergeFailureCode
.MERGE_ACCOUNT_NOT_ELIGIBLE);
}
lockEligibleAccounts(
actor.userId(),
secondaryUserId);
boolean secondaryAlreadyParticipates =
intentRepository
.findActiveByParticipantForUpdate(
secondaryUserId,
ACTIVE_STATUSES)
.stream()
.anyMatch(active ->
!active.getId().equals(
intentId));
if (secondaryAlreadyParticipates) {
throw failure(
AccountMergeFailureCode
.MERGE_CONFLICT);
}
Instant now = now();
intent.recordSecondaryProof(
secondaryUserId,
method,
now);
recordAudit(
actor,
"ACCOUNT_MERGE_SECONDARY_REAUTHENTICATED",
intent,
method);
return toIntent(intent);
}
@Transactional(noRollbackFor = AccountMergeException.class)
public AccountMergePreview preview(
AccountMergeActor actor,
UUID intentId) {
AccountMergeIntentEntity intent =
requireActiveIntent(actor, intentId);
if (intent.getStatus()
!= AccountMergeIntentStatus.READY_FOR_PREVIEW
&& intent.getStatus()
!= AccountMergeIntentStatus
.READY_TO_CONFIRM
&& intent.getStatus()
!= AccountMergeIntentStatus
.FAILED_CONFLICT) {
throw failure(
AccountMergeFailureCode
.MERGE_ALREADY_CONSUMED);
}
String secondaryUserId = requireSecondaryUserId(intent);
lockEligibleAccounts(
actor.userId(),
secondaryUserId);
Instant now = now();
AccountMergePlan plan = dataGateway.inspect(
actor.userId(),
secondaryUserId,
now);
int version = intent.recordPreview(
plan.digest(),
plan.confirmable(),
now);
recordAudit(
actor,
"ACCOUNT_MERGE_PREVIEWED",
intent,
plan.confirmable()
? "ready_to_confirm"
: "blocked_conflict");
intentRepository.flush();
return new AccountMergePreview(
intent.getId(),
intent.getStatus(),
version,
intent.getExpiresAt(),
plan);
}
@Transactional(
isolation = Isolation.SERIALIZABLE,
noRollbackFor = AccountMergeException.class)
public AccountMergeCompletion confirm(
AccountMergeActor actor,
UUID intentId,
int previewVersion) {
AccountMergeIntentEntity intent =
requireActiveIntent(actor, intentId);
if (intent.getStatus()
!= AccountMergeIntentStatus.READY_TO_CONFIRM
|| intent.getPreviewVersion() == null
|| intent.getPreviewVersion() != previewVersion) {
throw failure(
AccountMergeFailureCode
.MERGE_PREVIEW_STALE);
}
String secondaryUserId = requireSecondaryUserId(intent);
AccountPair accounts = lockEligibleAccounts(
actor.userId(),
secondaryUserId);
Instant now = now();
AccountMergePlan current = dataGateway.inspect(
actor.userId(),
secondaryUserId,
now);
if (!current.confirmable()
|| !current.digest().equals(
intent.getPreviewDigest())) {
intent.markPreviewStale(now);
recordAudit(
actor,
"ACCOUNT_MERGE_REJECTED",
intent,
"preview_stale");
intentRepository.flush();
throw failure(
AccountMergeFailureCode
.MERGE_PREVIEW_STALE);
}
recordAudit(
actor,
"ACCOUNT_MERGE_CONFIRMED",
intent,
"confirmed");
dataGateway.apply(
actor.userId(),
secondaryUserId,
intentId,
current,
now);
accounts.secondary().setStatus(UserStatus.MERGED);
accounts.secondary().setMergedToUserId(
accounts.primary().getId());
userRepository.save(accounts.secondary());
intent.complete(now);
recordAudit(
actor,
"ACCOUNT_MERGE_COMPLETED",
intent,
"completed");
intentRepository.flush();
return new AccountMergeCompletion(
intent.getId(),
intent.getStatus(),
intent.getCompletedAt());
}
@Transactional(noRollbackFor = AccountMergeException.class)
public AccountMergeIntent cancel(
AccountMergeActor actor,
UUID intentId) {
AccountMergeIntentEntity intent =
requireActiveIntent(actor, intentId);
Instant now = now();
intent.cancel(now);
recordAudit(
actor,
"ACCOUNT_MERGE_CANCELLED",
intent,
"cancelled");
intentRepository.flush();
return toIntent(intent);
}
@Transactional
public int expireDueIntents(int batchSize) {
if (batchSize < 1 || batchSize > 1000) {
throw new IllegalArgumentException(
"Invalid account merge expiration batch size");
}
Instant now = now();
List<AccountMergeIntentEntity> expired =
intentRepository.findExpiredForUpdate(
now,
ACTIVE_STATUSES,
PageRequest.of(0, batchSize));
for (AccountMergeIntentEntity intent : expired) {
intent.expire(now);
auditLogService.record(
intent.getPrimaryUserId(),
"ACCOUNT_MERGE_EXPIRED",
"ACCOUNT_MERGE_INTENT",
null,
null,
null,
null,
"{\"intentId\":\""
+ intent.getId()
+ "\",\"result\":\"expired\"}");
}
if (!expired.isEmpty()) {
intentRepository.flush();
}
return expired.size();
}
private void expireExistingIntent(
AccountMergeActor actor,
Instant now) {
for (AccountMergeIntentEntity existing
: intentRepository
.findActiveByParticipantForUpdate(
actor.userId(),
ACTIVE_STATUSES)) {
if (!existing.isExpiredAt(now)) {
throw failure(
AccountMergeFailureCode
.MERGE_CONFLICT);
}
existing.expire(now);
recordAudit(
actor,
"ACCOUNT_MERGE_EXPIRED",
existing,
"expired");
intentRepository.flush();
}
}
private AccountMergeIntentEntity requireActiveIntent(
AccountMergeActor actor,
UUID intentId) {
AccountMergeIntentEntity intent =
intentRepository.findByIdForUpdate(intentId)
.orElseThrow(() ->
failure(
AccountMergeFailureCode
.MERGE_INTENT_NOT_FOUND));
if (!intent.getPrimaryUserId().equals(
actor.userId())) {
throw failure(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH);
}
if (!stateHasher.matches(
actor.sessionNonce(),
intent.getPrimarySessionNonceHash())) {
throw failure(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH);
}
if (!intent.getStatus().isActive()) {
throw failure(
AccountMergeFailureCode
.MERGE_ALREADY_CONSUMED);
}
Instant now = now();
if (intent.isExpiredAt(now)) {
intent.expire(now);
recordAudit(
actor,
"ACCOUNT_MERGE_EXPIRED",
intent,
"expired");
intentRepository.flush();
throw failure(
AccountMergeFailureCode
.MERGE_PROOF_EXPIRED);
}
return intent;
}
private AccountPair lockEligibleAccounts(
String firstUserId,
String secondUserId) {
List<String> ordered = List.of(
firstUserId,
secondUserId)
.stream()
.sorted(Comparator.naturalOrder())
.toList();
UserAccount first = requireEligibleAccount(
ordered.get(0));
UserAccount second = requireEligibleAccount(
ordered.get(1));
UserAccount primary = first.getId().equals(firstUserId)
? first
: second;
UserAccount secondary = first.getId().equals(
secondUserId)
? first
: second;
return new AccountPair(primary, secondary);
}
private UserAccount requireEligiblePrimary(String userId) {
return requireEligibleAccount(userId);
}
private UserAccount requireEligibleAccount(String userId) {
UserAccount user = userRepository
.findByIdForUpdate(userId)
.orElseThrow(() ->
failure(
AccountMergeFailureCode
.MERGE_ACCOUNT_NOT_ELIGIBLE));
if (accountLoginGuard.evaluateInteractive(user)
!= AccountLoginDecision.ALLOWED
|| user.isSystemAccount()
|| user.getMergedToUserId() != null) {
throw failure(
AccountMergeFailureCode
.MERGE_ACCOUNT_NOT_ELIGIBLE);
}
return user;
}
private String requireSecondaryUserId(
AccountMergeIntentEntity intent) {
if (intent.getSecondaryUserId() == null) {
throw failure(
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED);
}
return intent.getSecondaryUserId();
}
private void recordAudit(
AccountMergeActor actor,
String action,
AccountMergeIntentEntity intent,
String result) {
IdentityLoginContext context = actor.auditContext();
auditLogService.record(
actor.userId(),
action,
"ACCOUNT_MERGE_INTENT",
null,
context.requestId(),
context.clientIp(),
context.userAgent(),
"{\"intentId\":\""
+ intent.getId()
+ "\",\"result\":\""
+ result
+ "\"}");
}
private AccountMergeIntent toIntent(
AccountMergeIntentEntity intent) {
return new AccountMergeIntent(
intent.getId(),
intent.getStatus(),
intent.getExpiresAt());
}
private Instant now() {
return Instant.now(clock);
}
private Instant earliest(Instant first, Instant second) {
return first.isBefore(second) ? first : second;
}
private AccountMergeException failure(
AccountMergeFailureCode code) {
return new AccountMergeException(code);
}
private record AccountPair(
UserAccount primary,
UserAccount secondary) {
}
}

View file

@ -0,0 +1,62 @@
package com.iflytek.skillhub.auth.merge;
import io.micrometer.core.instrument.MeterRegistry;
import java.util.Locale;
import org.springframework.stereotype.Component;
/**
* Low-cardinality account-merge metrics.
*
* <p>User IDs, intent IDs, session IDs, and request IDs are deliberately
* excluded from metric tags.
*/
@Component
public class AccountMergeMetrics {
private final MeterRegistry meterRegistry;
public AccountMergeMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
public void record(
String event,
String result) {
meterRegistry.counter(
"skillhub.account.merge",
"event",
normalized(event),
"result",
normalized(result)).increment();
}
public void recordProviderProof(
String providerCode,
String phase,
String result) {
meterRegistry.counter(
"skillhub.account.merge.provider.proof",
"provider",
providerCode,
"phase",
normalized(phase),
"result",
normalized(result)).increment();
}
public void recordSessionRevocation(String result) {
meterRegistry.counter(
"skillhub.account.merge.session.revocation",
"result",
normalized(result)).increment();
}
private String normalized(String value) {
if (value == null
|| value.isBlank()
|| value.length() > 64) {
return "unknown";
}
return value.toLowerCase(Locale.ROOT);
}
}

View file

@ -0,0 +1,256 @@
package com.iflytek.skillhub.auth.merge;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* Credential-free, deterministic migration plan for one account merge.
*
* <p>The digest covers the complete server-side snapshot. Public preview
* fields intentionally contain no provider subject, password material, token
* hash, session identifier, or raw proof.
*/
public record AccountMergePlan(
String digest,
List<String> identityProviders,
LocalCredentialAction localCredentialAction,
List<String> blockedPlatformRoles,
List<NamespaceChange> namespaceChanges,
List<ApiTokenView> apiTokensToRevoke,
int skillOwnershipCount,
SocialSummary social,
NotificationSummary notifications,
List<Conflict> conflicts
) {
private static final Pattern SHA256_PATTERN =
Pattern.compile("[0-9a-f]{64}");
public AccountMergePlan {
if (digest == null
|| !SHA256_PATTERN.matcher(digest).matches()) {
throw new IllegalArgumentException(
"Invalid account merge plan digest");
}
identityProviders = List.copyOf(identityProviders);
Objects.requireNonNull(
localCredentialAction,
"localCredentialAction");
blockedPlatformRoles =
List.copyOf(blockedPlatformRoles);
namespaceChanges = List.copyOf(namespaceChanges);
apiTokensToRevoke = List.copyOf(apiTokensToRevoke);
if (skillOwnershipCount < 0) {
throw new IllegalArgumentException(
"Invalid account merge skill count");
}
Objects.requireNonNull(social, "social");
Objects.requireNonNull(notifications, "notifications");
conflicts = List.copyOf(conflicts);
}
public boolean confirmable() {
return conflicts.isEmpty();
}
public enum LocalCredentialAction {
NONE,
MOVE_SECONDARY,
KEEP_PRIMARY_DELETE_SECONDARY
}
public enum ConflictCode {
IDENTITY_PROVIDER_CONFLICT(
ConflictResolutionAction
.REMOVE_DUPLICATE_IDENTITY),
PLATFORM_ROLE_CONFLICT(
ConflictResolutionAction
.REMOVE_SECONDARY_PLATFORM_ROLE),
NAMESPACE_OWNER_CONFLICT(
ConflictResolutionAction
.TRANSFER_NAMESPACE_OWNERSHIP),
SKILL_OWNERSHIP_CONFLICT(
ConflictResolutionAction
.REASSIGN_OR_RENAME_SKILL),
ACTIVE_IDENTITY_LINK(
ConflictResolutionAction
.COMPLETE_OR_CANCEL_IDENTITY_LINK),
PENDING_PROFILE_CHANGE(
ConflictResolutionAction
.COMPLETE_OR_CANCEL_PROFILE_CHANGE);
private final ConflictResolutionAction
suggestedAction;
ConflictCode(
ConflictResolutionAction suggestedAction) {
this.suggestedAction = suggestedAction;
}
public ConflictResolutionAction suggestedAction() {
return suggestedAction;
}
}
public enum ConflictResolutionAction {
REMOVE_DUPLICATE_IDENTITY,
REMOVE_SECONDARY_PLATFORM_ROLE,
TRANSFER_NAMESPACE_OWNERSHIP,
REASSIGN_OR_RENAME_SKILL,
COMPLETE_OR_CANCEL_IDENTITY_LINK,
COMPLETE_OR_CANCEL_PROFILE_CHANGE
}
public record NamespaceChange(
long namespaceId,
String namespaceSlug,
String primaryRole,
String secondaryRole,
String resultingRole,
boolean blocked
) {
public NamespaceChange {
if (namespaceId <= 0) {
throw new IllegalArgumentException(
"Invalid namespace id");
}
namespaceSlug = requireText(
namespaceSlug,
"namespaceSlug");
secondaryRole = requireText(
secondaryRole,
"secondaryRole");
resultingRole = requireText(
resultingRole,
"resultingRole");
}
}
public record ApiTokenView(
String name,
String prefix
) {
public ApiTokenView {
name = requireText(name, "tokenName");
prefix = requireText(prefix, "tokenPrefix");
}
}
public record SocialSummary(
int starsMoved,
int duplicateStarsDiscarded,
int ratingsMoved,
int duplicateRatingsDiscarded,
int subscriptionsMoved,
int duplicateSubscriptionsDiscarded,
List<DiscardedRating> discardedRatings
) {
public SocialSummary {
if (starsMoved < 0
|| duplicateStarsDiscarded < 0
|| ratingsMoved < 0
|| duplicateRatingsDiscarded < 0
|| subscriptionsMoved < 0
|| duplicateSubscriptionsDiscarded < 0) {
throw new IllegalArgumentException(
"Invalid account merge social summary");
}
discardedRatings = List.copyOf(
discardedRatings);
if (discardedRatings.size()
!= duplicateRatingsDiscarded) {
throw new IllegalArgumentException(
"Discarded rating details must match count");
}
}
public SocialSummary(
int starsMoved,
int duplicateStarsDiscarded,
int ratingsMoved,
int duplicateRatingsDiscarded,
int subscriptionsMoved,
int duplicateSubscriptionsDiscarded) {
this(
starsMoved,
duplicateStarsDiscarded,
ratingsMoved,
duplicateRatingsDiscarded,
subscriptionsMoved,
duplicateSubscriptionsDiscarded,
List.of());
if (duplicateRatingsDiscarded != 0) {
throw new IllegalArgumentException(
"Discarded rating details are required");
}
}
}
public record DiscardedRating(
long skillId,
int score
) {
public DiscardedRating {
if (skillId <= 0 || score < 1 || score > 5) {
throw new IllegalArgumentException(
"Invalid discarded account merge rating");
}
}
}
public record NotificationSummary(
int notificationsMoved,
int preferencesMoved,
int duplicatePreferencesDiscarded,
int governanceNotificationsMoved
) {
public NotificationSummary {
if (notificationsMoved < 0
|| preferencesMoved < 0
|| duplicatePreferencesDiscarded < 0
|| governanceNotificationsMoved < 0) {
throw new IllegalArgumentException(
"Invalid account merge notification summary");
}
}
}
public record Conflict(
ConflictCode code,
String resource,
ConflictResolutionAction suggestedAction
) {
public Conflict {
Objects.requireNonNull(code, "code");
resource = requireText(resource, "resource");
Objects.requireNonNull(
suggestedAction,
"suggestedAction");
if (suggestedAction != code.suggestedAction()) {
throw new IllegalArgumentException(
"Conflict action does not match code");
}
}
public Conflict(
ConflictCode code,
String resource) {
this(
code,
resource,
Objects.requireNonNull(
code,
"code").suggestedAction());
}
}
private static String requireText(
String value,
String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(
"Invalid account merge " + fieldName);
}
return value;
}
}

View file

@ -0,0 +1,27 @@
package com.iflytek.skillhub.auth.merge;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/**
* Versioned, credential-free preview returned by the merge workflow.
*/
public record AccountMergePreview(
UUID intentId,
AccountMergeIntentStatus status,
int previewVersion,
Instant expiresAt,
AccountMergePlan plan
) {
public AccountMergePreview {
Objects.requireNonNull(intentId, "intentId");
Objects.requireNonNull(status, "status");
if (previewVersion <= 0) {
throw new IllegalArgumentException(
"Invalid account merge preview version");
}
Objects.requireNonNull(expiresAt, "expiresAt");
Objects.requireNonNull(plan, "plan");
}
}

View file

@ -0,0 +1,30 @@
package com.iflytek.skillhub.auth.merge;
import java.time.Instant;
import java.util.Objects;
/**
* Non-sensitive metadata for a fresh primary-account proof.
*/
public record AccountMergePrimaryProof(
String method,
Instant authenticatedAt,
Instant expiresAt
) {
public AccountMergePrimaryProof {
if (method == null
|| method.isBlank()
|| method.length() > 96) {
throw new IllegalArgumentException(
"Invalid account merge proof method");
}
Objects.requireNonNull(
authenticatedAt,
"authenticatedAt");
Objects.requireNonNull(expiresAt, "expiresAt");
if (!expiresAt.isAfter(authenticatedAt)) {
throw new IllegalArgumentException(
"Account merge proof expiry must be in the future");
}
}
}

View file

@ -0,0 +1,32 @@
package com.iflytek.skillhub.auth.merge;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Release gate for the safe account-merge workflow.
*/
@Component
@ConfigurationProperties(prefix = "skillhub.auth.account-merge")
public class AccountMergeProperties {
private boolean enabled;
private boolean sessionCutoverComplete;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public boolean isSessionCutoverComplete() {
return sessionCutoverComplete;
}
public void setSessionCutoverComplete(
boolean sessionCutoverComplete) {
this.sessionCutoverComplete = sessionCutoverComplete;
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.auth.merge;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import java.util.Objects;
/**
* Server-side result of a successful primary provider reauthentication.
*/
public record AccountMergeProviderPrimaryProof(
PlatformPrincipal principal,
AccountMergePrimaryProof proof
) {
public AccountMergeProviderPrimaryProof {
Objects.requireNonNull(principal, "principal");
Objects.requireNonNull(proof, "proof");
}
}

View file

@ -0,0 +1,172 @@
package com.iflytek.skillhub.auth.merge;
import com.iflytek.skillhub.auth.identity.ExternalIdentityProof;
import com.iflytek.skillhub.auth.identity.ExternalIdentityProofService;
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.http.HttpSession;
import java.util.Objects;
import java.util.UUID;
import org.springframework.stereotype.Service;
/**
* Converts verified provider facts into primary or secondary account-merge
* ownership proofs without provisioning or changing the current principal.
*/
@Service
public class AccountMergeProviderProofService {
private final ExternalIdentityProofService identityProofService;
private final AccountMergeIntentService intentService;
private final AccountMergeSessionManager sessionManager;
private final AccountMergeMetrics metrics;
public AccountMergeProviderProofService(
ExternalIdentityProofService identityProofService,
AccountMergeIntentService intentService,
AccountMergeSessionManager sessionManager,
AccountMergeMetrics metrics) {
this.identityProofService = identityProofService;
this.intentService = intentService;
this.sessionManager = sessionManager;
this.metrics = metrics;
}
public AccountMergeProviderPrimaryProof completePrimary(
HttpSession session,
ResolvedProviderHandle provider,
ProviderAuthenticationResult result,
IdentityLoginContext context) {
try {
PlatformPrincipal primary = requirePrincipal(session);
ExternalIdentityProof proof = authenticate(
provider,
result,
context);
if (!primary.userId().equals(proof.userId())) {
throw failure(
AccountMergeFailureCode
.MERGE_ACCOUNT_NOT_ELIGIBLE);
}
AccountMergePrimaryProof primaryProof =
sessionManager.recordPrimaryReauthentication(
session,
primary.userId(),
proofMethod(proof));
metrics.recordProviderProof(
provider.providerCode(),
"primary",
"success");
return new AccountMergeProviderPrimaryProof(
primary,
primaryProof);
} catch (AccountMergeException exception) {
metrics.recordProviderProof(
provider.providerCode(),
"primary",
exception.getReasonCode().name());
throw exception;
}
}
public AccountMergeIntent completeSecondary(
AccountMergeActor actor,
UUID intentId,
ResolvedProviderHandle provider,
ProviderAuthenticationResult result,
IdentityLoginContext context) {
try {
ExternalIdentityProof proof = authenticate(
provider,
result,
context);
AccountMergeIntent intent =
intentService.recordSecondaryProof(
actor,
intentId,
proof.userId(),
proofMethod(proof));
metrics.recordProviderProof(
provider.providerCode(),
"secondary",
"success");
return intent;
} catch (AccountMergeException exception) {
metrics.recordProviderProof(
provider.providerCode(),
"secondary",
exception.getReasonCode().name());
throw exception;
}
}
private ExternalIdentityProof authenticate(
ResolvedProviderHandle provider,
ProviderAuthenticationResult result,
IdentityLoginContext context) {
try {
return identityProofService.authenticateExisting(
Objects.requireNonNull(provider, "provider"),
Objects.requireNonNull(result, "result"),
Objects.requireNonNull(context, "context"));
} catch (IdentityCoreException exception) {
throw failure(
map(exception.getReasonCode()),
exception);
}
}
private AccountMergeFailureCode map(
IdentityFailureCode reasonCode) {
return switch (reasonCode) {
case PROVIDER_DISABLED,
PROVIDER_AUTHORITY_MISMATCH ->
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE;
case INVALID_IDENTITY_ASSERTION,
IDENTITY_SUBJECT_MISSING,
IDENTITY_IDENTIFIER_CONFLICT ->
AccountMergeFailureCode
.MERGE_PROVIDER_AUTHENTICATION_FAILED;
case ACCESS_DENIED,
ACCOUNT_PENDING,
ACCOUNT_DISABLED,
ACCOUNT_MERGED,
SYSTEM_ACCOUNT_FORBIDDEN ->
AccountMergeFailureCode
.MERGE_ACCOUNT_NOT_ELIGIBLE;
};
}
private String proofMethod(ExternalIdentityProof proof) {
return "provider:" + proof.providerCode();
}
private PlatformPrincipal requirePrincipal(
HttpSession session) {
Object value = session == null
? null
: session.getAttribute("platformPrincipal");
if (!(value instanceof PlatformPrincipal principal)) {
throw failure(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH);
}
return principal;
}
private AccountMergeException failure(
AccountMergeFailureCode code) {
return new AccountMergeException(code);
}
private AccountMergeException failure(
AccountMergeFailureCode code,
RuntimeException cause) {
return new AccountMergeException(code, cause);
}
}

View file

@ -0,0 +1,468 @@
package com.iflytek.skillhub.auth.merge;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.io.Serial;
import java.io.Serializable;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* Owns raw, high-entropy Session state for safe account-merge workflows.
*
* <p>A fresh primary proof is stored only in the current Platform Session and
* consumed once when an intent is created. The intent then receives a separate
* Session nonce whose SHA-256 digest is persisted by the merge transaction.
*/
@Component
public class AccountMergeSessionManager {
static final Duration PRIMARY_PROOF_TTL =
Duration.ofMinutes(10);
private static final String PRIMARY_PROOF_ATTRIBUTE =
"skillhub.accountMerge.primaryProof";
private static final String INTENT_ATTRIBUTE_PREFIX =
"skillhub.accountMerge.intent.";
private static final String PENDING_BROWSER_FLOW_ATTRIBUTE =
"skillhub.accountMerge.browser.pending";
private static final String ACTIVE_BROWSER_FLOW_ATTRIBUTE =
"skillhub.accountMerge.browser.active";
private static final Duration BROWSER_FLOW_TTL =
Duration.ofMinutes(5);
private final SecureRandom secureRandom;
private final AccountMergeStateHasher stateHasher;
private final Clock clock;
@Autowired
public AccountMergeSessionManager(
AccountMergeStateHasher stateHasher,
Clock clock) {
this(new SecureRandom(), stateHasher, clock);
}
public AccountMergeSessionManager(Clock clock) {
this(
new SecureRandom(),
new AccountMergeStateHasher(),
clock);
}
AccountMergeSessionManager(
SecureRandom secureRandom,
Clock clock) {
this(
secureRandom,
new AccountMergeStateHasher(),
clock);
}
AccountMergeSessionManager(
SecureRandom secureRandom,
AccountMergeStateHasher stateHasher,
Clock clock) {
this.secureRandom = Objects.requireNonNull(
secureRandom,
"secureRandom");
this.stateHasher = Objects.requireNonNull(
stateHasher,
"stateHasher");
this.clock = Objects.requireNonNull(clock, "clock");
}
public AccountMergePrimaryProof recordPrimaryReauthentication(
HttpSession session,
String userId,
String method) {
PlatformPrincipal principal = requirePrincipal(session);
if (!principal.userId().equals(userId)) {
throw failure(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH);
}
Instant authenticatedAt = now();
Instant expiresAt =
authenticatedAt.plus(PRIMARY_PROOF_TTL);
PrimaryProofState proof = new PrimaryProofState(
userId,
requireText(method, "method", 96),
randomSecret(),
authenticatedAt,
expiresAt);
session.setAttribute(PRIMARY_PROOF_ATTRIBUTE, proof);
return new AccountMergePrimaryProof(
proof.method(),
proof.authenticatedAt(),
proof.expiresAt());
}
public AccountMergeActor startIntent(
HttpSession session,
UUID intentId,
IdentityLoginContext context) {
Objects.requireNonNull(intentId, "intentId");
PlatformPrincipal principal = requirePrincipal(session);
Object value = session.getAttribute(
PRIMARY_PROOF_ATTRIBUTE);
session.removeAttribute(PRIMARY_PROOF_ATTRIBUTE);
if (!(value instanceof PrimaryProofState proof)) {
throw failure(
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED);
}
if (!proof.userId().equals(principal.userId())) {
throw failure(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH);
}
if (!now().isBefore(proof.expiresAt())) {
throw failure(
AccountMergeFailureCode
.MERGE_PROOF_EXPIRED);
}
IntentSessionState intentState =
new IntentSessionState(
principal.userId(),
authenticationProvider(principal),
randomSecret(),
proof.method(),
proof.authenticatedAt());
session.setAttribute(
intentAttribute(intentId),
intentState);
return actor(
intentState,
Objects.requireNonNull(context, "context"));
}
public AccountMergeActor actor(
HttpSession session,
UUID intentId,
IdentityLoginContext context) {
PlatformPrincipal principal = requirePrincipal(session);
Object value = session.getAttribute(
intentAttribute(
Objects.requireNonNull(
intentId,
"intentId")));
if (!(value instanceof IntentSessionState state)
|| !state.userId().equals(principal.userId())) {
throw failure(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH);
}
return actor(
state,
Objects.requireNonNull(context, "context"));
}
public void remove(HttpSession session, UUID intentId) {
if (session == null || intentId == null) {
return;
}
session.removeAttribute(intentAttribute(intentId));
clearBrowserFlowForIntent(session, intentId);
}
public void preparePrimaryBrowserFlow(
HttpSession session,
String providerCode) {
PlatformPrincipal principal = requirePrincipal(session);
prepareBrowserFlow(
session,
new PendingBrowserFlow(
AccountMergeBrowserPhase
.PRIMARY_REAUTHENTICATION,
null,
principal.userId(),
requireText(
providerCode,
"providerCode",
64),
now().plus(BROWSER_FLOW_TTL)));
}
public void prepareSecondaryBrowserFlow(
HttpSession session,
UUID intentId,
String providerCode,
IdentityLoginContext context) {
AccountMergeActor actor = actor(
session,
intentId,
context);
prepareBrowserFlow(
session,
new PendingBrowserFlow(
AccountMergeBrowserPhase
.SECONDARY_AUTHENTICATION,
intentId,
actor.userId(),
requireText(
providerCode,
"providerCode",
64),
now().plus(BROWSER_FLOW_TTL)));
}
/**
* Binds the pending merge proof to OAuth/CAS raw browser state while
* persisting only a digest in the session.
*/
public void activateBrowserFlow(
HttpSession session,
String providerCode,
String browserState) {
if (session == null) {
return;
}
Object value = session.getAttribute(
PENDING_BROWSER_FLOW_ATTRIBUTE);
session.removeAttribute(PENDING_BROWSER_FLOW_ATTRIBUTE);
if (!(value instanceof PendingBrowserFlow pending)
|| !now().isBefore(pending.expiresAt())
|| !pending.providerCode().equals(providerCode)
|| browserState == null
|| browserState.isBlank()) {
return;
}
session.setAttribute(
ACTIVE_BROWSER_FLOW_ATTRIBUTE,
new ActiveBrowserFlow(
pending.phase(),
pending.intentId(),
pending.primaryUserId(),
pending.providerCode(),
stateHasher.hash(browserState),
pending.expiresAt()));
}
public Optional<AccountMergeBrowserFlow> consumeBrowserFlow(
HttpServletRequest request,
String providerCode,
IdentityLoginContext context) {
HttpSession session = request.getSession(false);
if (session == null) {
return Optional.empty();
}
Object value = session.getAttribute(
ACTIVE_BROWSER_FLOW_ATTRIBUTE);
if (!(value instanceof ActiveBrowserFlow active)) {
return Optional.empty();
}
session.removeAttribute(ACTIVE_BROWSER_FLOW_ATTRIBUTE);
String callbackState = request.getParameter("state");
PlatformPrincipal principal = requirePrincipal(session);
if (!now().isBefore(active.expiresAt())
|| !active.providerCode().equals(providerCode)
|| !active.primaryUserId().equals(
principal.userId())
|| !stateHasher.matches(
callbackState,
active.browserStateHash())) {
throw failure(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH);
}
if (active.phase()
== AccountMergeBrowserPhase
.PRIMARY_REAUTHENTICATION) {
return Optional.of(
new AccountMergeBrowserFlow.Primary(
principal.userId(),
active.providerCode()));
}
UUID intentId = Objects.requireNonNull(
active.intentId(),
"intentId");
return Optional.of(
new AccountMergeBrowserFlow.Secondary(
intentId,
actor(session, intentId, context),
active.providerCode()));
}
public Optional<AccountMergeBrowserFlowReference>
consumeFailedBrowserFlow(HttpSession session) {
if (session == null) {
return Optional.empty();
}
Object active = session.getAttribute(
ACTIVE_BROWSER_FLOW_ATTRIBUTE);
Object pending = session.getAttribute(
PENDING_BROWSER_FLOW_ATTRIBUTE);
clearBrowserFlow(session);
if (active instanceof ActiveBrowserFlow flow) {
return Optional.of(
new AccountMergeBrowserFlowReference(
flow.phase(),
flow.intentId()));
}
if (pending instanceof PendingBrowserFlow flow) {
return Optional.of(
new AccountMergeBrowserFlowReference(
flow.phase(),
flow.intentId()));
}
return Optional.empty();
}
public void clearBrowserFlow(HttpSession session) {
if (session == null) {
return;
}
session.removeAttribute(PENDING_BROWSER_FLOW_ATTRIBUTE);
session.removeAttribute(ACTIVE_BROWSER_FLOW_ATTRIBUTE);
}
private void prepareBrowserFlow(
HttpSession session,
PendingBrowserFlow pending) {
session.setAttribute(
PENDING_BROWSER_FLOW_ATTRIBUTE,
pending);
session.removeAttribute(ACTIVE_BROWSER_FLOW_ATTRIBUTE);
}
private void clearBrowserFlowForIntent(
HttpSession session,
UUID intentId) {
Object pending = session.getAttribute(
PENDING_BROWSER_FLOW_ATTRIBUTE);
if (pending instanceof PendingBrowserFlow flow
&& intentId.equals(flow.intentId())) {
session.removeAttribute(
PENDING_BROWSER_FLOW_ATTRIBUTE);
}
Object active = session.getAttribute(
ACTIVE_BROWSER_FLOW_ATTRIBUTE);
if (active instanceof ActiveBrowserFlow flow
&& intentId.equals(flow.intentId())) {
session.removeAttribute(
ACTIVE_BROWSER_FLOW_ATTRIBUTE);
}
}
private AccountMergeActor actor(
IntentSessionState state,
IdentityLoginContext context) {
return new AccountMergeActor(
state.userId(),
state.authenticationProvider(),
state.sessionNonce(),
state.primaryProofMethod(),
state.primaryProofAt(),
context);
}
private PlatformPrincipal requirePrincipal(
HttpSession session) {
Objects.requireNonNull(session, "session");
Object value = session.getAttribute(
"platformPrincipal");
if (!(value instanceof PlatformPrincipal principal)) {
throw failure(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH);
}
return principal;
}
private String authenticationProvider(
PlatformPrincipal principal) {
String provider = principal.oauthProvider();
return provider == null || provider.isBlank()
? "session"
: provider;
}
private String randomSecret() {
byte[] value = new byte[32];
secureRandom.nextBytes(value);
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(value);
}
private String intentAttribute(UUID intentId) {
return INTENT_ATTRIBUTE_PREFIX + intentId;
}
private Instant now() {
return Instant.now(clock);
}
private AccountMergeException failure(
AccountMergeFailureCode code) {
return new AccountMergeException(code);
}
private String requireText(
String value,
String fieldName,
int maximumLength) {
if (value == null
|| value.isBlank()
|| value.length() > maximumLength) {
throw new IllegalArgumentException(
"Invalid account merge " + fieldName);
}
return value;
}
private record PrimaryProofState(
String userId,
String method,
String rawProof,
Instant authenticatedAt,
Instant expiresAt
) implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
}
private record IntentSessionState(
String userId,
String authenticationProvider,
String sessionNonce,
String primaryProofMethod,
Instant primaryProofAt
) implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
}
private record PendingBrowserFlow(
AccountMergeBrowserPhase phase,
UUID intentId,
String primaryUserId,
String providerCode,
Instant expiresAt
) implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
}
private record ActiveBrowserFlow(
AccountMergeBrowserPhase phase,
UUID intentId,
String primaryUserId,
String providerCode,
String browserStateHash,
Instant expiresAt
) implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
}
}

View file

@ -0,0 +1,57 @@
package com.iflytek.skillhub.auth.merge;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import org.springframework.stereotype.Component;
@Component
final class AccountMergeStateHasher {
String hash(String rawState) {
if (rawState == null || rawState.isBlank()) {
throw new IllegalArgumentException(
"Account merge state must not be blank");
}
try {
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256")
.digest(rawState.getBytes(
StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(
"SHA-256 is unavailable",
exception);
}
}
boolean matches(
String rawState,
String expectedHash) {
if (rawState == null || expectedHash == null) {
return false;
}
byte[] expected;
try {
expected = HexFormat.of().parseHex(expectedHash);
} catch (IllegalArgumentException exception) {
return false;
}
return MessageDigest.isEqual(
digest(rawState),
expected);
}
private byte[] digest(String rawState) {
try {
return MessageDigest.getInstance("SHA-256")
.digest(rawState.getBytes(
StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(
"SHA-256 is unavailable",
exception);
}
}
}

View file

@ -2,6 +2,9 @@ package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.auth.merge.AccountMergeBrowserFlowReference;
import com.iflytek.skillhub.auth.merge.AccountMergeFailureCode;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@ -20,12 +23,18 @@ public class OAuth2LoginFailureHandler
private final OAuthLoginFlowService oauthLoginFlowService;
private final IdentityLinkSessionManager identityLinkSessionManager;
private final AccountMergeSessionManager
accountMergeSessionManager;
public OAuth2LoginFailureHandler(
OAuthLoginFlowService oauthLoginFlowService,
IdentityLinkSessionManager identityLinkSessionManager) {
IdentityLinkSessionManager identityLinkSessionManager,
AccountMergeSessionManager
accountMergeSessionManager) {
this.oauthLoginFlowService = oauthLoginFlowService;
this.identityLinkSessionManager = identityLinkSessionManager;
this.accountMergeSessionManager =
accountMergeSessionManager;
}
/**
@ -39,7 +48,33 @@ public class OAuth2LoginFailureHandler
HttpServletResponse response,
IdentityLinkFailureCode reasonCode)
throws IOException {
return redirectSecurityFlowRouteFailure(
request,
response,
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE,
reasonCode);
}
public boolean redirectSecurityFlowRouteFailure(
HttpServletRequest request,
HttpServletResponse response,
AccountMergeFailureCode accountMergeReason,
IdentityLinkFailureCode identityLinkReason)
throws IOException {
var session = request.getSession(false);
var accountMergeFlow = accountMergeSessionManager
.consumeFailedBrowserFlow(session);
if (accountMergeFlow.isPresent()) {
oauthLoginFlowService.consumeReturnTo(session);
getRedirectStrategy().sendRedirect(
request,
response,
accountMergeFailureTarget(
accountMergeFlow.orElseThrow(),
accountMergeReason.name()));
return true;
}
var intentId = identityLinkSessionManager
.consumeFailedBrowserFlow(session);
if (intentId.isEmpty()) {
@ -53,7 +88,7 @@ public class OAuth2LoginFailureHandler
+ "&intentId="
+ intentId.get()
+ "&reasonCode="
+ reasonCode.name());
+ identityLinkReason.name());
return true;
}
@ -65,6 +100,23 @@ public class OAuth2LoginFailureHandler
throws IOException, ServletException {
var session = request.getSession(false);
String returnTo = oauthLoginFlowService.consumeReturnTo(session);
var accountMergeFlow = accountMergeSessionManager
.consumeFailedBrowserFlow(session);
if (accountMergeFlow.isPresent()) {
String reasonCode = oauthLoginFlowService
.accountMergeFailureReasonCode(exception)
.orElse(
AccountMergeFailureCode
.MERGE_PROVIDER_AUTHENTICATION_FAILED
.name());
getRedirectStrategy().sendRedirect(
request,
response,
accountMergeFailureTarget(
accountMergeFlow.orElseThrow(),
reasonCode));
return;
}
String reasonCode = oauthLoginFlowService
.identityLinkFailureReasonCode(exception)
.orElse(
@ -90,4 +142,17 @@ public class OAuth2LoginFailureHandler
super.onAuthenticationFailure(request, response, exception);
}
private String accountMergeFailureTarget(
AccountMergeBrowserFlowReference flow,
String reasonCode) {
return "/settings/accounts?accountMerge=failed"
+ "&phase="
+ flow.phase().name()
+ (flow.intentId() == null
? ""
: "&intentId=" + flow.intentId())
+ "&reasonCode="
+ reasonCode;
}
}

View file

@ -15,6 +15,11 @@ import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle;
import com.iflytek.skillhub.auth.identity.TrustedProviderRouteResolver;
import com.iflytek.skillhub.auth.merge.AccountMergeBrowserFlow;
import com.iflytek.skillhub.auth.merge.AccountMergeException;
import com.iflytek.skillhub.auth.merge.AccountMergeFailureCode;
import com.iflytek.skillhub.auth.merge.AccountMergeProviderProofService;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@ -54,19 +59,28 @@ public class OAuthLoginFlowService {
private final ExternalIdentityLoginService identityLoginService;
private final ExternalIdentityLinkService identityLinkService;
private final IdentityLinkSessionManager identityLinkSessionManager;
private final AccountMergeSessionManager
accountMergeSessionManager;
private final AccountMergeProviderProofService
accountMergeProviderProofService;
@Autowired
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
TrustedProviderRouteResolver providerRouteResolver,
ExternalIdentityLoginService identityLoginService,
ExternalIdentityLinkService identityLinkService,
IdentityLinkSessionManager identityLinkSessionManager) {
IdentityLinkSessionManager identityLinkSessionManager,
AccountMergeSessionManager accountMergeSessionManager,
AccountMergeProviderProofService
accountMergeProviderProofService) {
this(
extractorList,
providerRouteResolver,
identityLoginService,
identityLinkService,
identityLinkSessionManager,
accountMergeSessionManager,
accountMergeProviderProofService,
new DefaultOAuth2UserService());
}
@ -76,6 +90,9 @@ public class OAuthLoginFlowService {
ExternalIdentityLoginService identityLoginService,
ExternalIdentityLinkService identityLinkService,
IdentityLinkSessionManager identityLinkSessionManager,
AccountMergeSessionManager accountMergeSessionManager,
AccountMergeProviderProofService
accountMergeProviderProofService,
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate) {
this.extractors = extractorList.stream()
.collect(Collectors.toMap(
@ -85,6 +102,10 @@ public class OAuthLoginFlowService {
this.identityLoginService = identityLoginService;
this.identityLinkService = identityLinkService;
this.identityLinkSessionManager = identityLinkSessionManager;
this.accountMergeSessionManager =
accountMergeSessionManager;
this.accountMergeProviderProofService =
accountMergeProviderProofService;
this.delegate = Objects.requireNonNull(delegate, "delegate");
}
@ -137,6 +158,15 @@ public class OAuthLoginFlowService {
ProviderAuthenticationResult result,
IdentityLoginContext context) {
try {
Optional<AccountMergeBrowserFlow> accountMergeFlow =
consumeAccountMergeFlow(provider, context);
if (accountMergeFlow.isPresent()) {
return authenticateAccountMergeFlow(
accountMergeFlow.orElseThrow(),
provider,
result,
context);
}
Optional<IdentityLinkBrowserFlow> identityLinkFlow =
consumeIdentityLinkFlow(provider, context);
if (identityLinkFlow.isPresent()) {
@ -166,9 +196,28 @@ public class OAuthLoginFlowService {
"identity_link_failed",
exception.getReasonCode().name(),
exception);
} catch (AccountMergeException exception) {
throw oauthFailure(
"account_merge_failed",
exception.getReasonCode().name(),
exception);
}
}
private Optional<AccountMergeBrowserFlow>
consumeAccountMergeFlow(
ResolvedProviderHandle provider,
IdentityLoginContext context) {
if (!(RequestContextHolder.getRequestAttributes()
instanceof ServletRequestAttributes attributes)) {
return Optional.empty();
}
return accountMergeSessionManager.consumeBrowserFlow(
attributes.getRequest(),
provider.providerCode(),
context);
}
private Optional<IdentityLinkBrowserFlow> consumeIdentityLinkFlow(
ResolvedProviderHandle provider,
IdentityLoginContext context) {
@ -216,6 +265,47 @@ public class OAuthLoginFlowService {
"Unsupported identity link outcome");
}
private PlatformPrincipal authenticateAccountMergeFlow(
AccountMergeBrowserFlow flow,
ResolvedProviderHandle provider,
ProviderAuthenticationResult result,
IdentityLoginContext context) {
HttpSession session = currentRequest()
.map(request -> request.getSession(false))
.orElseThrow(() ->
new AccountMergeException(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH));
if (flow instanceof AccountMergeBrowserFlow.Primary) {
return accountMergeProviderProofService
.completePrimary(
session,
provider,
result,
context)
.principal();
}
AccountMergeBrowserFlow.Secondary secondary =
(AccountMergeBrowserFlow.Secondary) flow;
accountMergeProviderProofService.completeSecondary(
secondary.actor(),
secondary.intentId(),
provider,
result,
context);
Object principalValue =
session.getAttribute("platformPrincipal");
if (!(principalValue
instanceof PlatformPrincipal principal)
|| !principal.userId().equals(
flow.primaryUserId())) {
throw new AccountMergeException(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH);
}
return principal;
}
private Optional<HttpServletRequest> currentRequest() {
if (RequestContextHolder.getRequestAttributes()
instanceof ServletRequestAttributes attributes) {
@ -267,6 +357,14 @@ public class OAuthLoginFlowService {
oauth2Exception.getError().getErrorCode()))) {
return "/access-denied";
}
if (exception instanceof OAuth2AuthenticationException oauth2Exception
&& "account_merge_failed".equals(
oauth2Exception.getError().getErrorCode())) {
return accountMergeFailureRedirect(
returnTo,
accountMergeFailureReasonCode(exception)
.orElse(null));
}
if (exception instanceof OAuth2AuthenticationException oauth2Exception
&& "identity_link_failed".equals(
oauth2Exception.getError().getErrorCode())) {
@ -300,11 +398,46 @@ public class OAuthLoginFlowService {
}
}
public Optional<String> accountMergeFailureReasonCode(
AuthenticationException exception) {
if (!(exception
instanceof OAuth2AuthenticationException oauth2Exception)
|| !"account_merge_failed".equals(
oauth2Exception.getError().getErrorCode())) {
return Optional.empty();
}
String description =
oauth2Exception.getError().getDescription();
try {
return Optional.of(
AccountMergeFailureCode.valueOf(
description).name());
} catch (IllegalArgumentException | NullPointerException ignored) {
return Optional.empty();
}
}
private String identityLinkFailureRedirect(
String returnTo,
String reasonCode) {
Optional<UUID> intentId = identityLinkIntentId(returnTo);
return "/settings/security?identityLink=failed"
+ intentId.map(id -> "&intentId=" + id)
.orElse("")
+ (reasonCode == null
? ""
: "&reasonCode="
+ URLEncoder.encode(
reasonCode,
StandardCharsets.UTF_8));
}
private String accountMergeFailureRedirect(
String returnTo,
String reasonCode) {
Optional<UUID> intentId =
accountMergeIntentId(returnTo);
return "/settings/accounts?accountMerge=failed"
+ intentId.map(id -> "&intentId=" + id)
.orElse("")
+ (reasonCode == null
@ -339,6 +472,36 @@ public class OAuthLoginFlowService {
return Optional.empty();
}
private Optional<UUID> accountMergeIntentId(
String returnTo) {
if (returnTo == null
|| !returnTo.startsWith(
"/settings/accounts?")) {
return Optional.empty();
}
return intentIdParameter(returnTo);
}
private Optional<UUID> intentIdParameter(String returnTo) {
String query = returnTo.substring(
returnTo.indexOf('?') + 1);
for (String parameter : query.split("&")) {
int separator = parameter.indexOf('=');
if (separator <= 0
|| !"intentId".equals(
parameter.substring(0, separator))) {
continue;
}
try {
return Optional.of(UUID.fromString(
parameter.substring(separator + 1)));
} catch (IllegalArgumentException ignored) {
return Optional.empty();
}
}
return Optional.empty();
}
public record AuthenticatedLoginContext(OAuth2User upstreamUser, PlatformPrincipal principal) {
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
@ -18,16 +19,22 @@ public class SkillHubOAuth2AuthorizationRequestResolver
private final DefaultOAuth2AuthorizationRequestResolver delegate;
private final OAuthLoginFlowService oauthLoginFlowService;
private final IdentityLinkSessionManager identityLinkSessionManager;
private final AccountMergeSessionManager
accountMergeSessionManager;
public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository,
OAuthLoginFlowService oauthLoginFlowService,
IdentityLinkSessionManager identityLinkSessionManager) {
IdentityLinkSessionManager identityLinkSessionManager,
AccountMergeSessionManager
accountMergeSessionManager) {
this.delegate = new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
"/oauth2/authorization"
);
this.oauthLoginFlowService = oauthLoginFlowService;
this.identityLinkSessionManager = identityLinkSessionManager;
this.accountMergeSessionManager =
accountMergeSessionManager;
}
@Override
@ -57,5 +64,9 @@ public class SkillHubOAuth2AuthorizationRequestResolver
request.getSession(false),
registrationId,
authorizationRequest.getState());
accountMergeSessionManager.activateBrowserFlow(
request.getSession(false),
registrationId,
authorizationRequest.getState());
}
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.auth.rbac;
import java.io.Serializable;
import java.security.Principal;
import java.util.Set;
/**
@ -13,4 +14,15 @@ public record PlatformPrincipal(
String avatarUrl,
String oauthProvider,
Set<String> platformRoles
) implements Serializable {}
) implements Principal, Serializable {
/**
* Spring Session indexes authenticated sessions by
* {@link Principal#getName()}. The immutable platform user ID is the only
* stable name across local, OAuth, OIDC, CAS, and credential providers.
*/
@Override
public String getName() {
return userId;
}
}

View file

@ -0,0 +1,232 @@
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.identity.AccountLoginGuard;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Optional;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Pageable;
class AccountMergeIntentTransactionTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-07-31T09:00:00Z"),
ZoneOffset.UTC);
private static final UUID INTENT_ID = UUID.fromString(
"7e67f099-8d10-4ec3-a24a-e170726f62b8");
private AccountMergeIntentRepository intentRepository;
private UserAccountRepository userRepository;
private AccountMergeDataGateway dataGateway;
private AuditLogService auditLogService;
private AccountMergeIntentTransaction transaction;
private AccountMergeActor actor;
@BeforeEach
void setUp() {
intentRepository =
mock(AccountMergeIntentRepository.class);
userRepository = mock(UserAccountRepository.class);
dataGateway = mock(AccountMergeDataGateway.class);
auditLogService = mock(AuditLogService.class);
transaction = new AccountMergeIntentTransaction(
intentRepository,
userRepository,
new AccountLoginGuard(),
new AccountMergeStateHasher(),
dataGateway,
auditLogService,
CLOCK);
actor = new AccountMergeActor(
"usr_primary",
"local",
"high-entropy-session-nonce",
"local-password",
Instant.parse("2026-07-31T08:59:00Z"),
new IdentityLoginContext(
"req-merge-1",
"203.0.113.10",
"Browser"));
when(userRepository.findByIdForUpdate(
"usr_primary")).thenReturn(Optional.of(
new UserAccount(
"usr_primary",
"Primary",
"primary@example.com",
null)));
when(intentRepository.saveAndFlush(any()))
.thenAnswer(invocation ->
invocation.getArgument(0));
}
@Test
void createsIntentWithoutAcceptingASecondaryIdentifier() {
AccountMergeIntent intent =
transaction.createIntent(actor, INTENT_ID);
assertThat(intent.id()).isEqualTo(INTENT_ID);
assertThat(intent.status()).isEqualTo(
AccountMergeIntentStatus
.PENDING_SECONDARY_PROOF);
assertThat(intent.expiresAt()).isEqualTo(
Instant.parse("2026-07-31T09:09:00Z"));
verify(intentRepository).saveAndFlush(any(
AccountMergeIntentEntity.class));
verify(auditLogService).record(
"usr_primary",
"ACCOUNT_MERGE_PRIMARY_REAUTHENTICATED",
"ACCOUNT_MERGE_INTENT",
null,
"req-merge-1",
"203.0.113.10",
"Browser",
"{\"intentId\":\""
+ INTENT_ID
+ "\",\"result\":\"local-password\"}");
verify(auditLogService).record(
"usr_primary",
"ACCOUNT_MERGE_INTENT_CREATED",
"ACCOUNT_MERGE_INTENT",
null,
"req-merge-1",
"203.0.113.10",
"Browser",
"{\"intentId\":\""
+ INTENT_ID
+ "\",\"result\":"
+ "\"pending_secondary_proof\"}");
}
@Test
void rejectsAnIneligiblePrimaryAccount() {
UserAccount systemAccount = UserAccount.systemAccount(
"usr_primary",
"System",
null,
null);
when(userRepository.findByIdForUpdate(
"usr_primary")).thenReturn(
Optional.of(systemAccount));
assertThatThrownBy(() ->
transaction.createIntent(actor, INTENT_ID))
.isInstanceOfSatisfying(
AccountMergeException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
AccountMergeFailureCode
.MERGE_ACCOUNT_NOT_ELIGIBLE));
}
@Test
void recordsSecondaryProofOnlyAfterIndependentAuthentication() {
AccountMergeIntentEntity intent =
new AccountMergeIntentEntity(
INTENT_ID,
"usr_primary",
new AccountMergeStateHasher().hash(
"high-entropy-session-nonce"),
"local-password",
Instant.parse(
"2026-07-31T08:59:00Z"),
Instant.parse(
"2026-07-31T09:10:00Z"),
Instant.parse(
"2026-07-31T09:00:00Z"));
when(intentRepository.findByIdForUpdate(INTENT_ID))
.thenReturn(Optional.of(intent));
when(userRepository.findByIdForUpdate(
"usr_secondary")).thenReturn(Optional.of(
new UserAccount(
"usr_secondary",
"Secondary",
"secondary@example.com",
null)));
when(intentRepository
.findActiveByParticipantForUpdate(
"usr_secondary",
AccountMergeIntentTransactionTest
.activeStatuses()))
.thenReturn(List.of());
AccountMergeIntent result =
transaction.recordSecondaryProof(
actor,
INTENT_ID,
"usr_secondary",
"local-password");
assertThat(result.status()).isEqualTo(
AccountMergeIntentStatus.READY_FOR_PREVIEW);
assertThat(intent.getSecondaryUserId())
.isEqualTo("usr_secondary");
}
@Test
void backgroundCleanupExpiresAndAuditsDueIntents() {
AccountMergeIntentEntity expired =
new AccountMergeIntentEntity(
INTENT_ID,
"usr_primary",
new AccountMergeStateHasher().hash(
"high-entropy-session-nonce"),
"local-password",
Instant.parse(
"2026-07-31T08:49:00Z"),
Instant.parse(
"2026-07-31T08:59:00Z"),
Instant.parse(
"2026-07-31T08:49:00Z"));
when(intentRepository.findExpiredForUpdate(
any(),
any(),
any(Pageable.class)))
.thenReturn(List.of(expired));
assertThat(transaction.expireDueIntents(100))
.isEqualTo(1);
assertThat(expired.getStatus()).isEqualTo(
AccountMergeIntentStatus.EXPIRED);
verify(auditLogService).record(
"usr_primary",
"ACCOUNT_MERGE_EXPIRED",
"ACCOUNT_MERGE_INTENT",
null,
null,
null,
null,
"{\"intentId\":\""
+ INTENT_ID
+ "\",\"result\":\"expired\"}");
verify(intentRepository).flush();
}
private static java.util.Set<AccountMergeIntentStatus>
activeStatuses() {
return java.util.Set.of(
AccountMergeIntentStatus
.PENDING_SECONDARY_PROOF,
AccountMergeIntentStatus
.READY_FOR_PREVIEW,
AccountMergeIntentStatus
.READY_TO_CONFIRM,
AccountMergeIntentStatus
.FAILED_CONFLICT);
}
}

View file

@ -0,0 +1,193 @@
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.identity.ExternalIdentityProof;
import com.iflytek.skillhub.auth.identity.ExternalIdentityProofService;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle;
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandleTestFixture;
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpSession;
class AccountMergeProviderProofServiceTest {
private static final Instant NOW =
Instant.parse("2026-07-31T10:00:00Z");
private static final IdentityLoginContext CONTEXT =
new IdentityLoginContext(
"req-provider-proof",
"203.0.113.20",
"JUnit");
private static final ResolvedProviderHandle PROVIDER =
ResolvedProviderHandleTestFixture.handle("github");
private ExternalIdentityProofService identityProofService;
private AccountMergeIntentService intentService;
private AccountMergeSessionManager sessionManager;
private AccountMergeProviderProofService service;
private MockHttpSession session;
@BeforeEach
void setUp() {
identityProofService =
mock(ExternalIdentityProofService.class);
intentService =
mock(AccountMergeIntentService.class);
sessionManager = new AccountMergeSessionManager(
Clock.fixed(NOW, ZoneOffset.UTC));
service = new AccountMergeProviderProofService(
identityProofService,
intentService,
sessionManager,
mock(AccountMergeMetrics.class));
session = new MockHttpSession();
session.setAttribute(
"platformPrincipal",
principal("usr_primary"));
}
@Test
void primaryProofMustResolveToTheExistingSessionAccount() {
when(identityProofService.authenticateExisting(
PROVIDER,
result(),
CONTEXT)).thenReturn(new ExternalIdentityProof(
"usr_primary",
"github",
"oauth2",
NOW));
AccountMergeProviderPrimaryProof completed =
service.completePrimary(
session,
PROVIDER,
result(),
CONTEXT);
assertThat(completed.principal().userId())
.isEqualTo("usr_primary");
assertThat(completed.proof().method())
.isEqualTo("provider:github");
assertThat(completed.proof().expiresAt())
.isEqualTo(NOW.plusSeconds(600));
}
@Test
void providerCannotReauthenticateAnotherAccountAsPrimary() {
when(identityProofService.authenticateExisting(
PROVIDER,
result(),
CONTEXT)).thenReturn(new ExternalIdentityProof(
"usr_secondary",
"github",
"oauth2",
NOW));
assertThatThrownBy(() ->
service.completePrimary(
session,
PROVIDER,
result(),
CONTEXT))
.isInstanceOfSatisfying(
AccountMergeException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
AccountMergeFailureCode
.MERGE_ACCOUNT_NOT_ELIGIBLE));
assertThatThrownBy(() ->
sessionManager.startIntent(
session,
UUID.randomUUID(),
CONTEXT))
.isInstanceOfSatisfying(
AccountMergeException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED));
}
@Test
void secondaryProofComesOnlyFromExistingIdentityResolution() {
UUID intentId = UUID.randomUUID();
AccountMergeActor actor = new AccountMergeActor(
"usr_primary",
"local",
"high-entropy-nonce",
"local-password",
NOW,
CONTEXT);
AccountMergeIntent expected = new AccountMergeIntent(
intentId,
AccountMergeIntentStatus.READY_FOR_PREVIEW,
NOW.plusSeconds(600));
when(identityProofService.authenticateExisting(
PROVIDER,
result(),
CONTEXT)).thenReturn(new ExternalIdentityProof(
"usr_secondary",
"github",
"oauth2",
NOW));
when(intentService.recordSecondaryProof(
actor,
intentId,
"usr_secondary",
"provider:github")).thenReturn(expected);
assertThat(service.completeSecondary(
actor,
intentId,
PROVIDER,
result(),
CONTEXT)).isSameAs(expected);
verify(intentService).recordSecondaryProof(
actor,
intentId,
"usr_secondary",
"provider:github");
}
private static ProviderAuthenticationResult result() {
return new ProviderAuthenticationResult(
new SubjectCandidate(
"github_user_id",
"123"),
List.of(),
Map.of(),
new ProtocolAuthenticationEvidence(
"oauth2-github",
NOW,
Set.of("oauth2_authorization_code")));
}
private static PlatformPrincipal principal(String userId) {
return new PlatformPrincipal(
userId,
"User",
"user@example.com",
null,
"github",
Set.of("USER"));
}
}

View file

@ -0,0 +1,201 @@
package com.iflytek.skillhub.auth.merge;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
class AccountMergeSessionManagerTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-07-31T09:00:00Z"),
ZoneOffset.UTC);
private static final IdentityLoginContext CONTEXT =
new IdentityLoginContext(
"req-merge-1",
"203.0.113.10",
"Browser");
private AccountMergeSessionManager manager;
private MockHttpSession session;
@BeforeEach
void setUp() {
manager = new AccountMergeSessionManager(
new SecureRandom(),
CLOCK);
session = new MockHttpSession();
session.setAttribute(
"platformPrincipal",
new PlatformPrincipal(
"usr_primary",
"Primary",
"primary@example.com",
null,
"local",
Set.of("USER")));
}
@Test
void creatingIntentWithoutFreshReauthenticationFailsClosed() {
assertThatThrownBy(() -> manager.startIntent(
session,
UUID.randomUUID(),
CONTEXT))
.isInstanceOfSatisfying(
AccountMergeException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED));
}
@Test
void freshLocalProofIsConsumedOnceAndRawValuesStayOutOfToString() {
AccountMergePrimaryProof proof =
manager.recordPrimaryReauthentication(
session,
"usr_primary",
"local-password");
AccountMergeActor actor = manager.startIntent(
session,
UUID.randomUUID(),
CONTEXT);
assertThat(proof.authenticatedAt())
.isEqualTo(Instant.parse("2026-07-31T09:00:00Z"));
assertThat(proof.expiresAt())
.isEqualTo(Instant.parse("2026-07-31T09:10:00Z"));
assertThat(actor.userId()).isEqualTo("usr_primary");
assertThat(actor.primaryProofMethod())
.isEqualTo("local-password");
assertThat(actor.toString())
.contains("usr_primary")
.doesNotContain("nonce")
.doesNotContain("proof");
assertThatThrownBy(() -> manager.startIntent(
session,
UUID.randomUUID(),
CONTEXT))
.isInstanceOfSatisfying(
AccountMergeException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
AccountMergeFailureCode
.MERGE_REAUTH_REQUIRED));
}
@Test
void primaryBrowserProofIsBoundToStateAndConsumedOnce() {
String state = "oauth-state-primary";
manager.preparePrimaryBrowserFlow(session, "github");
manager.activateBrowserFlow(
session,
"github",
state);
MockHttpServletRequest request =
callbackRequest(state);
AccountMergeBrowserFlow flow =
manager.consumeBrowserFlow(
request,
"github",
CONTEXT)
.orElseThrow();
assertThat(flow)
.isInstanceOf(
AccountMergeBrowserFlow.Primary.class);
assertThat(flow.primaryUserId())
.isEqualTo("usr_primary");
assertThat(manager.consumeBrowserFlow(
request,
"github",
CONTEXT)).isEmpty();
}
@Test
void secondaryBrowserProofRetainsPrimaryActorAndIntent() {
UUID intentId = UUID.randomUUID();
manager.recordPrimaryReauthentication(
session,
"usr_primary",
"local-password");
manager.startIntent(session, intentId, CONTEXT);
manager.prepareSecondaryBrowserFlow(
session,
intentId,
"cas-main",
CONTEXT);
manager.activateBrowserFlow(
session,
"cas-main",
"cas-state");
AccountMergeBrowserFlow.Secondary flow =
(AccountMergeBrowserFlow.Secondary)
manager.consumeBrowserFlow(
callbackRequest("cas-state"),
"cas-main",
CONTEXT)
.orElseThrow();
assertThat(flow.intentId()).isEqualTo(intentId);
assertThat(flow.actor().userId())
.isEqualTo("usr_primary");
assertThat(flow.providerCode())
.isEqualTo("cas-main");
}
@Test
void browserStateMismatchFailsClosedAndCannotBeRetried() {
manager.preparePrimaryBrowserFlow(session, "github");
manager.activateBrowserFlow(
session,
"github",
"expected-state");
MockHttpServletRequest request =
callbackRequest("different-state");
assertThatThrownBy(() ->
manager.consumeBrowserFlow(
request,
"github",
CONTEXT))
.isInstanceOfSatisfying(
AccountMergeException.class,
exception -> assertThat(
exception.getReasonCode())
.isEqualTo(
AccountMergeFailureCode
.MERGE_SESSION_MISMATCH));
assertThat(manager.consumeBrowserFlow(
request,
"github",
CONTEXT)).isEmpty();
}
private MockHttpServletRequest callbackRequest(
String state) {
MockHttpServletRequest request =
new MockHttpServletRequest();
request.setSession(session);
request.setParameter("state", state);
return request;
}
}

View file

@ -4,6 +4,8 @@ import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService;
import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.TrustedProviderRouteResolver;
import com.iflytek.skillhub.auth.merge.AccountMergeProviderProofService;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import jakarta.servlet.http.HttpSession;
@ -22,12 +24,16 @@ import org.springframework.security.oauth2.client.registration.InMemoryClientReg
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class OAuth2AuthorizationRequestResolverTest {
private SkillHubOAuth2AuthorizationRequestResolver resolver;
private OAuthLoginFlowService oauthLoginFlowService;
private AccountMergeSessionManager accountMergeSessionManager;
@BeforeEach
void setUp() {
@ -48,12 +54,17 @@ class OAuth2AuthorizationRequestResolverTest {
mock(TrustedProviderRouteResolver.class),
mock(ExternalIdentityLoginService.class),
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class)
mock(IdentityLinkSessionManager.class),
mock(AccountMergeSessionManager.class),
mock(AccountMergeProviderProofService.class)
);
accountMergeSessionManager =
mock(AccountMergeSessionManager.class);
resolver = new SkillHubOAuth2AuthorizationRequestResolver(
new InMemoryClientRegistrationRepository(github),
oauthLoginFlowService,
mock(IdentityLinkSessionManager.class)
mock(IdentityLinkSessionManager.class),
accountMergeSessionManager
);
}
@ -112,6 +123,11 @@ class OAuth2AuthorizationRequestResolverTest {
assertThat(session).isNotNull();
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE))
.isEqualTo("/dashboard/publish?draft=1");
verify(accountMergeSessionManager)
.activateBrowserFlow(
eq(session),
eq("github"),
anyString());
}
@Test

View file

@ -2,6 +2,10 @@ package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import com.iflytek.skillhub.auth.merge.AccountMergeBrowserFlowReference;
import com.iflytek.skillhub.auth.merge.AccountMergeBrowserPhase;
import com.iflytek.skillhub.auth.merge.AccountMergeFailureCode;
import jakarta.servlet.http.HttpSession;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
@ -110,7 +114,8 @@ class OAuth2LoginHandlersTest {
OAuthLoginFlowService oauthLoginFlowService = mock(OAuthLoginFlowService.class);
OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(
oauthLoginFlowService,
mock(IdentityLinkSessionManager.class));
mock(IdentityLinkSessionManager.class),
mock(AccountMergeSessionManager.class));
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
HttpSession session = request.getSession(true);
@ -147,7 +152,8 @@ class OAuth2LoginHandlersTest {
OAuth2LoginFailureHandler handler =
new OAuth2LoginFailureHandler(
oauthLoginFlowService,
sessionManager);
sessionManager,
mock(AccountMergeSessionManager.class));
MockHttpServletRequest request =
new MockHttpServletRequest();
MockHttpServletResponse response =
@ -189,7 +195,8 @@ class OAuth2LoginHandlersTest {
OAuth2LoginFailureHandler handler =
new OAuth2LoginFailureHandler(
oauthLoginFlowService,
sessionManager);
sessionManager,
mock(AccountMergeSessionManager.class));
MockHttpServletRequest request =
new MockHttpServletRequest();
MockHttpServletResponse response =
@ -234,7 +241,8 @@ class OAuth2LoginHandlersTest {
OAuth2LoginFailureHandler handler =
new OAuth2LoginFailureHandler(
oauthLoginFlowService,
sessionManager);
sessionManager,
mock(AccountMergeSessionManager.class));
MockHttpServletRequest request =
new MockHttpServletRequest();
MockHttpServletResponse response =
@ -261,4 +269,60 @@ class OAuth2LoginHandlersTest {
org.mockito.Mockito.verify(oauthLoginFlowService)
.consumeReturnTo(session);
}
@Test
void failureHandlerPreservesAccountMergeIntentAndReason()
throws Exception {
OAuthLoginFlowService oauthLoginFlowService =
mock(OAuthLoginFlowService.class);
AccountMergeSessionManager sessionManager =
mock(AccountMergeSessionManager.class);
OAuth2LoginFailureHandler handler =
new OAuth2LoginFailureHandler(
oauthLoginFlowService,
mock(IdentityLinkSessionManager.class),
sessionManager);
MockHttpServletRequest request =
new MockHttpServletRequest();
MockHttpServletResponse response =
new MockHttpServletResponse();
HttpSession session = request.getSession(true);
UUID intentId = UUID.randomUUID();
OAuth2AuthenticationException failure =
new OAuth2AuthenticationException(
new OAuth2Error(
"account_merge_failed",
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE
.name(),
null));
org.mockito.Mockito.when(
sessionManager.consumeFailedBrowserFlow(session))
.thenReturn(Optional.of(
new AccountMergeBrowserFlowReference(
AccountMergeBrowserPhase
.SECONDARY_AUTHENTICATION,
intentId)));
org.mockito.Mockito.when(
oauthLoginFlowService
.accountMergeFailureReasonCode(failure))
.thenReturn(Optional.of(
AccountMergeFailureCode
.MERGE_PROVIDER_UNAVAILABLE
.name()));
handler.onAuthenticationFailure(
request,
response,
failure);
assertThat(response.getRedirectedUrl())
.isEqualTo(
"/settings/accounts?accountMerge=failed"
+ "&phase=SECONDARY_AUTHENTICATION"
+ "&intentId="
+ intentId
+ "&reasonCode="
+ "MERGE_PROVIDER_UNAVAILABLE");
}
}

View file

@ -29,6 +29,14 @@ import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle;
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandleTestFixture;
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
import com.iflytek.skillhub.auth.identity.TrustedProviderRouteResolver;
import com.iflytek.skillhub.auth.merge.AccountMergeProviderProofService;
import com.iflytek.skillhub.auth.merge.AccountMergeSessionManager;
import com.iflytek.skillhub.auth.merge.AccountMergeActor;
import com.iflytek.skillhub.auth.merge.AccountMergeBrowserFlow;
import com.iflytek.skillhub.auth.merge.AccountMergeIntent;
import com.iflytek.skillhub.auth.merge.AccountMergeIntentStatus;
import com.iflytek.skillhub.auth.merge.AccountMergePrimaryProof;
import com.iflytek.skillhub.auth.merge.AccountMergeProviderPrimaryProof;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.http.HttpSession;
import java.time.Instant;
@ -92,6 +100,8 @@ class OAuthLoginFlowServiceTest {
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class),
mock(AccountMergeSessionManager.class),
mock(AccountMergeProviderProofService.class),
delegate);
clearInvocations(extractor);
@ -134,6 +144,8 @@ class OAuthLoginFlowServiceTest {
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class),
mock(AccountMergeSessionManager.class),
mock(AccountMergeProviderProofService.class),
delegate);
clearInvocations(extractor);
@ -161,7 +173,9 @@ class OAuthLoginFlowServiceTest {
resolver,
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class));
mock(IdentityLinkSessionManager.class),
mock(AccountMergeSessionManager.class),
mock(AccountMergeProviderProofService.class));
PlatformPrincipal principal = principal();
when(identityLoginService.authenticate(any(), any(), any()))
.thenReturn(new IdentityLoginOutcome.Authenticated(
@ -194,7 +208,9 @@ class OAuthLoginFlowServiceTest {
resolver,
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class));
mock(IdentityLinkSessionManager.class),
mock(AccountMergeSessionManager.class),
mock(AccountMergeProviderProofService.class));
when(identityLoginService.authenticate(any(), any(), any()))
.thenReturn(new IdentityLoginOutcome.PendingApproval(
"ACCOUNT_PENDING"));
@ -219,7 +235,9 @@ class OAuthLoginFlowServiceTest {
resolver,
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class));
mock(IdentityLinkSessionManager.class),
mock(AccountMergeSessionManager.class),
mock(AccountMergeProviderProofService.class));
when(identityLoginService.authenticate(any(), any(), any()))
.thenReturn(new IdentityLoginOutcome.LinkRequired(
"EMAIL_COLLISION"));
@ -256,7 +274,9 @@ class OAuthLoginFlowServiceTest {
resolver,
identityLoginService,
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class));
mock(IdentityLinkSessionManager.class),
mock(AccountMergeSessionManager.class),
mock(AccountMergeProviderProofService.class));
when(identityLoginService.authenticate(any(), any(), any()))
.thenThrow(new IdentityCoreException(
IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH));
@ -290,7 +310,9 @@ class OAuthLoginFlowServiceTest {
resolver,
identityLoginService,
identityLinkService,
sessionManager);
sessionManager,
mock(AccountMergeSessionManager.class),
mock(AccountMergeProviderProofService.class));
ResolvedProviderHandle provider =
ResolvedProviderHandleTestFixture.handle("github");
UUID intentId = UUID.randomUUID();
@ -336,6 +358,134 @@ class OAuthLoginFlowServiceTest {
intentId);
}
@Test
void primaryAccountMergeProofTakesPriorityAndKeepsPrimaryPrincipal() {
ExternalIdentityLoginService identityLoginService =
mock(ExternalIdentityLoginService.class);
ExternalIdentityLinkService identityLinkService =
mock(ExternalIdentityLinkService.class);
IdentityLinkSessionManager identityLinkSessions =
mock(IdentityLinkSessionManager.class);
AccountMergeSessionManager accountMergeSessions =
mock(AccountMergeSessionManager.class);
AccountMergeProviderProofService proofService =
mock(AccountMergeProviderProofService.class);
OAuthLoginFlowService service =
new OAuthLoginFlowService(
List.of(),
mock(TrustedProviderRouteResolver.class),
identityLoginService,
identityLinkService,
identityLinkSessions,
accountMergeSessions,
proofService);
ResolvedProviderHandle provider =
ResolvedProviderHandleTestFixture.handle("github");
MockHttpServletRequest request =
callbackRequest(principal());
RequestContextHolder.setRequestAttributes(
new ServletRequestAttributes(request));
AccountMergeBrowserFlow flow =
new AccountMergeBrowserFlow.Primary(
"usr_1",
"github");
when(accountMergeSessions.consumeBrowserFlow(
request,
"github",
context())).thenReturn(Optional.of(flow));
when(proofService.completePrimary(
request.getSession(false),
provider,
result(),
context())).thenReturn(
new AccountMergeProviderPrimaryProof(
principal(),
new AccountMergePrimaryProof(
"provider:github",
Instant.parse(
"2026-07-30T08:00:00Z"),
Instant.parse(
"2026-07-30T08:10:00Z"))));
PlatformPrincipal authenticated = service.authenticate(
provider,
result(),
context());
assertThat(authenticated.userId()).isEqualTo("usr_1");
verifyNoInteractions(
identityLoginService,
identityLinkService);
}
@Test
void secondaryAccountMergeProofNeverReplacesPrimaryPrincipal() {
ExternalIdentityLoginService identityLoginService =
mock(ExternalIdentityLoginService.class);
ExternalIdentityLinkService identityLinkService =
mock(ExternalIdentityLinkService.class);
AccountMergeSessionManager accountMergeSessions =
mock(AccountMergeSessionManager.class);
AccountMergeProviderProofService proofService =
mock(AccountMergeProviderProofService.class);
OAuthLoginFlowService service =
new OAuthLoginFlowService(
List.of(),
mock(TrustedProviderRouteResolver.class),
identityLoginService,
identityLinkService,
mock(IdentityLinkSessionManager.class),
accountMergeSessions,
proofService);
ResolvedProviderHandle provider =
ResolvedProviderHandleTestFixture.handle("github");
MockHttpServletRequest request =
callbackRequest(principal());
RequestContextHolder.setRequestAttributes(
new ServletRequestAttributes(request));
UUID intentId = UUID.randomUUID();
AccountMergeActor actor = new AccountMergeActor(
"usr_1",
"local",
"session-nonce",
"local-password",
Instant.parse("2026-07-30T08:00:00Z"),
context());
AccountMergeBrowserFlow.Secondary flow =
new AccountMergeBrowserFlow.Secondary(
intentId,
actor,
"github");
when(accountMergeSessions.consumeBrowserFlow(
request,
"github",
context())).thenReturn(Optional.of(flow));
when(proofService.completeSecondary(
actor,
intentId,
provider,
result(),
context())).thenReturn(new AccountMergeIntent(
intentId,
AccountMergeIntentStatus.READY_FOR_PREVIEW,
Instant.parse(
"2026-07-30T08:10:00Z")));
PlatformPrincipal authenticated = service.authenticate(
provider,
result(),
context());
assertThat(authenticated)
.isEqualTo(
request.getSession(false)
.getAttribute("platformPrincipal"));
assertThat(authenticated.userId()).isEqualTo("usr_1");
verifyNoInteractions(
identityLoginService,
identityLinkService);
}
@Test
void rememberReturnToStoresSanitizedReturnTarget() {
OAuthLoginFlowService service = service();
@ -440,7 +590,21 @@ class OAuthLoginFlowServiceTest {
mock(TrustedProviderRouteResolver.class),
mock(ExternalIdentityLoginService.class),
mock(ExternalIdentityLinkService.class),
mock(IdentityLinkSessionManager.class));
mock(IdentityLinkSessionManager.class),
mock(AccountMergeSessionManager.class),
mock(AccountMergeProviderProofService.class));
}
private static MockHttpServletRequest callbackRequest(
PlatformPrincipal principal) {
MockHttpServletRequest request =
new MockHttpServletRequest(
"GET",
"/login/oauth2/code/github");
request.getSession(true).setAttribute(
"platformPrincipal",
principal);
return request;
}
private static ProviderAuthenticationResult result() {

Some files were not shown because too many files have changed in this diff Show more