mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
feat(auth): add secure account merging
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
e7bde3e177
commit
fd49ad9170
118 changed files with 15371 additions and 159 deletions
|
|
@ -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.
|
||||
|
|
|
|||
3
.github/workflows/pr-e2e.yml
vendored
3
.github/workflows/pr-e2e.yml
vendored
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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)。
|
||||
|
||||
#### 数据库架构支持边界
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" -}}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 管理员
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -46,6 +46,13 @@ data:
|
|||
# Session 配置
|
||||
# HTTP 环境设为 false,HTTPS 环境设为 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
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
103
docs/24-account-merge-user-reference-inventory.md
Normal file
103
docs/24-account-merge-user-reference-inventory.md
Normal 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.
|
||||
199
docs/25-secure-account-merge-operations.md
Normal file
199
docs/25-secure-account-merge-operations.md
Normal 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 schema;additive 表可以由旧应用忽略。
|
||||
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 或高基数用户标识。
|
||||
- 隔离测试资源已精确清理,既有服务测试前后健康且未重启宿主机。
|
||||
|
||||
缺少任一证据时保持功能关闭,不以管理员手工改库或“页面能打开”替代。
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 操作。
|
||||
|
||||
|
|
|
|||
159
scripts/tests/account-merge-integration-test.sh
Executable file
159
scripts/tests/account-merge-integration-test.sh
Executable 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"
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
public record AccountMergeConfirmRequest(
|
||||
@Min(1) int previewVersion
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record AccountMergePrimaryProofResponse(
|
||||
String method,
|
||||
Instant expiresAt
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -255,7 +255,6 @@ class IdentityLinkMigrationLoginPostgresIntegrationTest {
|
|||
.schemas(SCHEMA)
|
||||
.defaultSchema(SCHEMA)
|
||||
.createSchemas(true)
|
||||
.target(MigrationVersion.fromVersion("49"))
|
||||
.load()
|
||||
.migrate();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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/"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue