From fd49ad91700cdf0736e3aaa3d021b99470c9a36e Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:07:43 +0800 Subject: [PATCH] feat(auth): add secure account merging Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.example | 11 + .github/workflows/pr-e2e.yml | 3 + charts/skillhub/README.md | 9 + charts/skillhub/templates/configmap.yaml | 5 + .../skillhub/templates/server-deployment.yaml | 25 + charts/skillhub/templates/validate.yaml | 6 + charts/skillhub/values.schema.json | 20 +- charts/skillhub/values.yaml | 8 + compose.release.yml | 5 + deploy/k8s/base/backend-deployment.yaml | 25 + deploy/k8s/base/configmap.yaml | 7 + ...-secure-account-merge-acceptance-design.md | 6 + ...-account-merge-user-reference-inventory.md | 103 ++ docs/25-secure-account-merge-operations.md | 199 +++ docs/skillhub/en/faq.md | 8 +- docs/skillhub/faq.md | 8 +- .../tests/account-merge-integration-test.sh | 159 ++ ...ccountMergeSessionRevocationReadiness.java | 32 + .../controller/AccountMergeController.java | 335 +++- .../AccountMergeMutationResponses.java | 72 + ...ountMergeAuthenticationMethodResponse.java | 35 + ...ountMergeBrowserAuthenticationRequest.java | 11 + .../dto/AccountMergeBrowserStartResponse.java | 14 + .../dto/AccountMergeCapabilitiesResponse.java | 18 + .../dto/AccountMergeCompletionResponse.java | 12 + .../dto/AccountMergeConfirmRequest.java | 8 + ...tMergeCredentialAuthenticationRequest.java | 17 + .../dto/AccountMergeErrorResponse.java | 31 + .../dto/AccountMergeIntentResponse.java | 18 + ...ountMergeLocalReauthenticationRequest.java | 11 + .../dto/AccountMergePreviewResponse.java | 74 + .../dto/AccountMergePrimaryProofResponse.java | 9 + ...geSecondaryLocalAuthenticationRequest.java | 20 + .../skillhub/dto/ApiResponseFactory.java | 30 + .../exception/GlobalExceptionHandler.java | 50 + .../AccountMergeDataRepository.java | 1278 +++++++++++++++ ...countMergeSessionRevocationRepository.java | 142 ++ .../service/AccountMergeAppService.java | 686 ++++++++ .../skillhub/service/CasLoginAppService.java | 254 ++- .../service/IdentityLinkAppService.java | 11 +- .../ProviderAuthenticationFailureMapper.java | 21 + .../task/AccountMergeIntentCleanupTask.java | 41 + .../AccountMergeSessionRevocationTask.java | 165 ++ .../src/main/resources/application.yml | 5 + .../migration/V50__account_merge_intent.sql | 178 +++ .../src/main/resources/messages.properties | 16 + .../src/main/resources/messages_zh.properties | 16 + ...MigrationLoginPostgresIntegrationTest.java | 1 - ...countMergeIntentMigrationPostgresTest.java | 235 +++ .../AccountMergePostgresIntegrationTest.java | 1410 ++++++++++++++++ ...ntMergeSessionRevocationReadinessTest.java | 95 ++ .../config/RedisClusterIntegrationTest.java | 24 + .../AccountMergeControllerEnabledTest.java | 147 ++ .../AccountMergeControllerTest.java | 44 + .../filter/AuthContextFilterTest.java | 40 + ...ssionRevocationRepositoryPostgresTest.java | 330 ++++ .../service/AccountMergeAppServiceTest.java | 340 ++++ .../service/CasLoginAppServiceTest.java | 178 +++ .../service/IdentityLinkAppServiceTest.java | 11 +- .../AccountMergeIntentCleanupTaskTest.java | 26 + ...countMergeSessionRedisIntegrationTest.java | 283 ++++ ...AccountMergeSessionRevocationTaskTest.java | 194 +++ .../AccountMergeRouteRequestMatcher.java | 27 + .../DefaultExternalIdentityProofService.java | 48 + .../auth/identity/ExternalIdentityProof.java | 43 + .../ExternalIdentityProofService.java | 13 + .../IdentityResolutionTransaction.java | 64 + .../auth/merge/AccountMergeActor.java | 94 ++ .../auth/merge/AccountMergeBrowserFlow.java | 66 + .../AccountMergeBrowserFlowReference.java | 12 + .../auth/merge/AccountMergeBrowserPhase.java | 9 + .../auth/merge/AccountMergeCompletion.java | 23 + .../auth/merge/AccountMergeDataGateway.java | 27 + .../auth/merge/AccountMergeException.java | 30 + .../auth/merge/AccountMergeFailureCode.java | 60 + .../auth/merge/AccountMergeIntent.java | 14 + .../auth/merge/AccountMergeIntentEntity.java | 342 ++++ .../merge/AccountMergeIntentRepository.java | 60 + .../auth/merge/AccountMergeIntentService.java | 233 +++ .../auth/merge/AccountMergeIntentStatus.java | 21 + .../merge/AccountMergeIntentTransaction.java | 498 ++++++ .../auth/merge/AccountMergeMetrics.java | 62 + .../skillhub/auth/merge/AccountMergePlan.java | 256 +++ .../auth/merge/AccountMergePreview.java | 27 + .../auth/merge/AccountMergePrimaryProof.java | 30 + .../auth/merge/AccountMergeProperties.java | 32 + .../AccountMergeProviderPrimaryProof.java | 17 + .../AccountMergeProviderProofService.java | 172 ++ .../merge/AccountMergeSessionManager.java | 468 ++++++ .../auth/merge/AccountMergeStateHasher.java | 57 + .../auth/oauth/OAuth2LoginFailureHandler.java | 69 +- .../auth/oauth/OAuthLoginFlowService.java | 165 +- ...HubOAuth2AuthorizationRequestResolver.java | 13 +- .../skillhub/auth/rbac/PlatformPrincipal.java | 14 +- .../AccountMergeIntentTransactionTest.java | 232 +++ .../AccountMergeProviderProofServiceTest.java | 193 +++ .../merge/AccountMergeSessionManagerTest.java | 201 +++ ...Auth2AuthorizationRequestResolverTest.java | 20 +- .../auth/oauth/OAuth2LoginHandlersTest.java | 72 +- .../auth/oauth/OAuthLoginFlowServiceTest.java | 176 +- .../auth/rbac/PlatformPrincipalTest.java | 24 + .../ApiTokenAuthenticationFilterTest.java | 37 + .../notification/sse/SseEmitterManager.java | 25 + .../sse/SseEmitterManagerTest.java | 35 + web/e2e/settings-routing.spec.ts | 8 +- web/src/api/client.test.ts | 94 ++ web/src/api/client.ts | 458 +++++- web/src/api/generated/schema.d.ts | 1418 ++++++++++++++++- web/src/api/types.ts | 139 +- web/src/app/router.tsx | 12 +- .../auth/account-merge-wizard.test.tsx | 187 +++ .../features/auth/account-merge-wizard.tsx | 1136 +++++++++++++ .../features/auth/use-account-merge.test.ts | 26 +- web/src/features/auth/use-account-merge.ts | 160 +- web/src/i18n/locales/en.json | 92 +- web/src/i18n/locales/zh.json | 92 +- web/src/pages/settings/accounts.test.ts | 30 +- web/src/pages/settings/accounts.tsx | 22 +- 118 files changed, 15371 insertions(+), 159 deletions(-) create mode 100644 docs/24-account-merge-user-reference-inventory.md create mode 100644 docs/25-secure-account-merge-operations.md create mode 100755 scripts/tests/account-merge-integration-test.sh create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AccountMergeSessionRevocationReadiness.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeMutationResponses.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeAuthenticationMethodResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeBrowserAuthenticationRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeBrowserStartResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCapabilitiesResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCompletionResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeConfirmRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCredentialAuthenticationRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeErrorResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeIntentResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeLocalReauthenticationRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergePreviewResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergePrimaryProofResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeSecondaryLocalAuthenticationRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AccountMergeDataRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AccountMergeSessionRevocationRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AccountMergeAppService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/task/AccountMergeIntentCleanupTask.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/task/AccountMergeSessionRevocationTask.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V50__account_merge_intent.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentMigrationPostgresTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergePostgresIntegrationTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AccountMergeSessionRevocationReadinessTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerEnabledTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/AccountMergeSessionRevocationRepositoryPostgresTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AccountMergeAppServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeIntentCleanupTaskTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeSessionRedisIntegrationTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeSessionRevocationTaskTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/AccountMergeRouteRequestMatcher.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityProofService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityProof.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityProofService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeActor.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserFlow.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserFlowReference.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserPhase.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeCompletion.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeDataGateway.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeException.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeFailureCode.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntent.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentEntity.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentRepository.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentStatus.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentTransaction.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeMetrics.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePlan.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePreview.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePrimaryProof.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProperties.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderPrimaryProof.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderProofService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeSessionManager.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeStateHasher.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentTransactionTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderProofServiceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeSessionManagerTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipalTest.java create mode 100644 web/src/features/auth/account-merge-wizard.test.tsx create mode 100644 web/src/features/auth/account-merge-wizard.tsx diff --git a/.env.release.example b/.env.release.example index b0a96e0e..0cba6bf4 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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. diff --git a/.github/workflows/pr-e2e.yml b/.github/workflows/pr-e2e.yml index edbdce40..7752ac81 100644 --- a/.github/workflows/pr-e2e.yml +++ b/.github/workflows/pr-e2e.yml @@ -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 \ diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 9c736f0c..f059dce8 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -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)。 #### 数据库架构支持边界 diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml index 258c8dd5..0c5561ff 100644 --- a/charts/skillhub/templates/configmap.yaml +++ b/charts/skillhub/templates/configmap.yaml @@ -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 }} diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index b589f9ab..4620fdae 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -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 diff --git a/charts/skillhub/templates/validate.yaml b/charts/skillhub/templates/validate.yaml index 03af8ecf..ee6bc4cc 100644 --- a/charts/skillhub/templates/validate.yaml +++ b/charts/skillhub/templates/validate.yaml @@ -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" -}} diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 10950c7a..b1de1ac6 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -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", diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 06c84251..e699342a 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -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 管理员 diff --git a/compose.release.yml b/compose.release.yml index 81cd5688..e8d83049 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -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} diff --git a/deploy/k8s/base/backend-deployment.yaml b/deploy/k8s/base/backend-deployment.yaml index 60eb97d3..ad3757dc 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -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 diff --git a/deploy/k8s/base/configmap.yaml b/deploy/k8s/base/configmap.yaml index 903a019c..f2bd6329 100644 --- a/deploy/k8s/base/configmap.yaml +++ b/deploy/k8s/base/configmap.yaml @@ -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 diff --git a/docs/22-secure-account-merge-acceptance-design.md b/docs/22-secure-account-merge-acceptance-design.md index 8d881552..4630b65b 100644 --- a/docs/22-secure-account-merge-acceptance-design.md +++ b/docs/22-secure-account-merge-acceptance-design.md @@ -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。 diff --git a/docs/24-account-merge-user-reference-inventory.md b/docs/24-account-merge-user-reference-inventory.md new file mode 100644 index 00000000..d8bd5a2e --- /dev/null +++ b/docs/24-account-merge-user-reference-inventory.md @@ -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. diff --git a/docs/25-secure-account-merge-operations.md b/docs/25-secure-account-merge-operations.md new file mode 100644 index 00000000..569d3652 --- /dev/null +++ b/docs/25-secure-account-merge-operations.md @@ -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 或高基数用户标识。 +- 隔离测试资源已精确清理,既有服务测试前后健康且未重启宿主机。 + +缺少任一证据时保持功能关闭,不以管理员手工改库或“页面能打开”替代。 diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index 5b6c8dd7..4256a607 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -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. diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index a9de4e5e..4ec4c6f5 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -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 操作。 diff --git a/scripts/tests/account-merge-integration-test.sh b/scripts/tests/account-merge-integration-test.sh new file mode 100755 index 00000000..2d32da6f --- /dev/null +++ b/scripts/tests/account-merge-integration-test.sh @@ -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" diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AccountMergeSessionRevocationReadiness.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AccountMergeSessionRevocationReadiness.java new file mode 100644 index 00000000..1fbe10e1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AccountMergeSessionRevocationReadiness.java @@ -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"); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java index c196af72..dc13a94a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java @@ -1,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. * - *

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. + *

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 + 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 + 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 + 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 + 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 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 + 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 + 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 + 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 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 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 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 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; + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeMutationResponses.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeMutationResponses.java new file mode 100644 index 00000000..18b4cb8a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeMutationResponses.java @@ -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 { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeAuthenticationMethodResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeAuthenticationMethodResponse.java new file mode 100644 index 00000000..49f26790 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeAuthenticationMethodResponse.java @@ -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; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeBrowserAuthenticationRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeBrowserAuthenticationRequest.java new file mode 100644 index 00000000..020bed27 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeBrowserAuthenticationRequest.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeBrowserStartResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeBrowserStartResponse.java new file mode 100644 index 00000000..9818cc94 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeBrowserStartResponse.java @@ -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"); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCapabilitiesResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCapabilitiesResponse.java new file mode 100644 index 00000000..6418d1a2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCapabilitiesResponse.java @@ -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 primaryMethods, + List secondaryMethods +) { + public AccountMergeCapabilitiesResponse { + primaryMethods = List.copyOf(primaryMethods); + secondaryMethods = List.copyOf(secondaryMethods); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCompletionResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCompletionResponse.java new file mode 100644 index 00000000..d8e5b9f3 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCompletionResponse.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeConfirmRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeConfirmRequest.java new file mode 100644 index 00000000..489b9728 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeConfirmRequest.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Min; + +public record AccountMergeConfirmRequest( + @Min(1) int previewVersion +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCredentialAuthenticationRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCredentialAuthenticationRequest.java new file mode 100644 index 00000000..d7bb6ffb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeCredentialAuthenticationRequest.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeErrorResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeErrorResponse.java new file mode 100644 index 00000000..ad839649 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeErrorResponse.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeIntentResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeIntentResponse.java new file mode 100644 index 00000000..326dd5ae --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeIntentResponse.java @@ -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 + secondaryMethods +) { + public AccountMergeIntentResponse { + secondaryMethods = List.copyOf(secondaryMethods); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeLocalReauthenticationRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeLocalReauthenticationRequest.java new file mode 100644 index 00000000..2dc6fa09 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeLocalReauthenticationRequest.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergePreviewResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergePreviewResponse.java new file mode 100644 index 00000000..f84f136f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergePreviewResponse.java @@ -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 identityProviders, + String localCredentialAction, + List blockedPlatformRoles, + List namespaceChanges, + List apiTokensToRevoke, + int skillOwnershipCount, + SocialSummary social, + NotificationSummary notifications, + List 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 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 + ) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergePrimaryProofResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergePrimaryProofResponse.java new file mode 100644 index 00000000..b150cdfa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergePrimaryProofResponse.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record AccountMergePrimaryProofResponse( + String method, + Instant expiresAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeSecondaryLocalAuthenticationRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeSecondaryLocalAuthenticationRequest.java new file mode 100644 index 00000000..1e0b955c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AccountMergeSecondaryLocalAuthenticationRequest.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java index 6f987885..27b7cef8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java @@ -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()); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index 0b942d3b..f7104ebc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -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 + 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> 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")); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AccountMergeDataRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AccountMergeDataRepository.java new file mode 100644 index 00000000..a0d84094 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AccountMergeDataRepository.java @@ -0,0 +1,1278 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.auth.merge.AccountMergeDataGateway; +import com.iflytek.skillhub.auth.merge.AccountMergePlan; +import com.iflytek.skillhub.auth.merge.AccountMergePlan.ApiTokenView; +import com.iflytek.skillhub.auth.merge.AccountMergePlan.Conflict; +import com.iflytek.skillhub.auth.merge.AccountMergePlan.ConflictCode; +import com.iflytek.skillhub.auth.merge.AccountMergePlan.DiscardedRating; +import com.iflytek.skillhub.auth.merge.AccountMergePlan.LocalCredentialAction; +import com.iflytek.skillhub.auth.merge.AccountMergePlan.NamespaceChange; +import com.iflytek.skillhub.auth.merge.AccountMergePlan.NotificationSummary; +import com.iflytek.skillhub.auth.merge.AccountMergePlan.SocialSummary; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * PostgreSQL implementation of the Account Merge cross-aggregate boundary. + * + *

Direct SQL is intentional here: one confirmation must inspect and mutate + * authentication, authorization, skill, social, notification, and transient + * security tables in one serializable transaction. Routing the workflow + * through each aggregate repository would make the snapshot and lock order + * implicit and would risk partially applying the merge. + */ +@Repository +public class AccountMergeDataRepository + implements AccountMergeDataGateway { + + private final JdbcTemplate jdbcTemplate; + + public AccountMergeDataRepository( + JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public AccountMergePlan inspect( + String primaryUserId, + String secondaryUserId, + Instant now) { + SnapshotDigest digest = new SnapshotDigest(); + List conflicts = new ArrayList<>(); + + List accounts = accounts( + primaryUserId, + secondaryUserId); + digest.rows("account", accounts); + + List bindings = bindings( + primaryUserId, + secondaryUserId); + digest.rows("binding", bindings); + Set primaryProviders = providers( + bindings, + primaryUserId); + Set secondaryProviders = providers( + bindings, + secondaryUserId); + primaryProviders.stream() + .filter(secondaryProviders::contains) + .sorted() + .forEach(provider -> conflicts.add( + new Conflict( + ConflictCode + .IDENTITY_PROVIDER_CONFLICT, + provider))); + + List credentials = credentials( + primaryUserId, + secondaryUserId); + digest.rows("credential", credentials); + boolean primaryCredential = credentials.stream() + .anyMatch(row -> row.userId().equals( + primaryUserId)); + boolean secondaryCredential = credentials.stream() + .anyMatch(row -> row.userId().equals( + secondaryUserId)); + LocalCredentialAction credentialAction = + localCredentialAction( + primaryCredential, + secondaryCredential); + + List roles = roles( + primaryUserId, + secondaryUserId); + digest.rows("role", roles); + List blockedRoles = roles.stream() + .filter(row -> row.userId().equals( + secondaryUserId)) + .map(RoleRow::roleCode) + .distinct() + .sorted() + .toList(); + blockedRoles.forEach(role -> conflicts.add( + new Conflict( + ConflictCode.PLATFORM_ROLE_CONFLICT, + role))); + + List activeLinks = activeIdentityLinks( + primaryUserId, + secondaryUserId, + now); + digest.rows("identity-link", activeLinks); + activeLinks.forEach(link -> conflicts.add( + new Conflict( + ConflictCode.ACTIVE_IDENTITY_LINK, + link.providerCode()))); + + List memberships = memberships( + primaryUserId, + secondaryUserId); + digest.rows("membership", memberships); + List namespaceChanges = + namespaceChanges( + memberships, + primaryUserId, + secondaryUserId, + conflicts); + + List tokens = unrevokedTokens( + secondaryUserId); + digest.rows("api-token", tokens); + List tokenViews = tokens.stream() + .map(token -> new ApiTokenView( + token.name(), + token.prefix())) + .toList(); + + List skills = ownedSkills( + primaryUserId, + secondaryUserId); + digest.rows("skill", skills); + Set primarySkillCoordinates = skills.stream() + .filter(skill -> skill.userId().equals( + primaryUserId)) + .map(SkillRow::coordinate) + .collect(java.util.stream.Collectors.toSet()); + skills.stream() + .filter(skill -> skill.userId().equals( + secondaryUserId)) + .filter(skill -> primarySkillCoordinates.contains( + skill.coordinate())) + .map(SkillRow::coordinate) + .sorted() + .forEach(coordinate -> conflicts.add( + new Conflict( + ConflictCode + .SKILL_OWNERSHIP_CONFLICT, + coordinate))); + + List stars = socialRows( + "skill_star", + primaryUserId, + secondaryUserId, + false); + digest.rows("star", stars); + List ratings = socialRows( + "skill_rating", + primaryUserId, + secondaryUserId, + true); + digest.rows("rating", ratings); + List subscriptions = socialRows( + "skill_subscription", + primaryUserId, + secondaryUserId, + false); + digest.rows("subscription", subscriptions); + SocialSummary social = new SocialSummary( + movedCount(stars, primaryUserId, secondaryUserId), + duplicateCount( + stars, + primaryUserId, + secondaryUserId), + movedCount( + ratings, + primaryUserId, + secondaryUserId), + duplicateCount( + ratings, + primaryUserId, + secondaryUserId), + movedCount( + subscriptions, + primaryUserId, + secondaryUserId), + duplicateCount( + subscriptions, + primaryUserId, + secondaryUserId), + discardedRatings( + ratings, + primaryUserId, + secondaryUserId)); + + List pendingProfiles = + pendingProfileChanges(secondaryUserId); + digest.rows("profile-change", pendingProfiles); + pendingProfiles.forEach(change -> conflicts.add( + new Conflict( + ConflictCode.PENDING_PROFILE_CHANGE, + "profile-change"))); + + List passwordResets = + passwordResets(secondaryUserId); + digest.rows("password-reset", passwordResets); + + List notifications = + notifications(secondaryUserId); + digest.rows("notification", notifications); + List preferences = preferences( + primaryUserId, + secondaryUserId); + digest.rows("notification-preference", preferences); + List governanceNotifications = + governanceNotifications(secondaryUserId); + digest.rows( + "governance-notification", + governanceNotifications); + int duplicatePreferences = duplicatePreferences( + preferences, + primaryUserId, + secondaryUserId); + int secondaryPreferences = (int) preferences.stream() + .filter(row -> row.userId().equals( + secondaryUserId)) + .count(); + NotificationSummary notificationSummary = + new NotificationSummary( + notifications.size(), + secondaryPreferences + - duplicatePreferences, + duplicatePreferences, + governanceNotifications.size()); + + conflicts.sort(Comparator + .comparing((Conflict conflict) -> + conflict.code().name()) + .thenComparing(Conflict::resource)); + return new AccountMergePlan( + digest.finish(), + secondaryProviders.stream().sorted().toList(), + credentialAction, + blockedRoles, + namespaceChanges, + tokenViews, + (int) skills.stream() + .filter(skill -> skill.userId().equals( + secondaryUserId)) + .count(), + social, + notificationSummary, + conflicts); + } + + @Override + public void apply( + String primaryUserId, + String secondaryUserId, + UUID intentId, + AccountMergePlan plan, + Instant now) { + if (!plan.confirmable()) { + throw new IllegalArgumentException( + "Blocked account merge plan cannot be applied"); + } + Timestamp timestamp = Timestamp.from(now); + moveBindings(primaryUserId, secondaryUserId, timestamp); + moveCredential( + primaryUserId, + secondaryUserId, + plan.localCredentialAction(), + timestamp); + moveMemberships( + primaryUserId, + secondaryUserId, + plan.namespaceChanges(), + timestamp); + moveSkills(primaryUserId, secondaryUserId, timestamp); + moveSocialState( + "skill_star", + "star_count", + primaryUserId, + secondaryUserId); + moveRatings(primaryUserId, secondaryUserId); + moveSocialState( + "skill_subscription", + "subscription_count", + primaryUserId, + secondaryUserId); + revokeTokens(secondaryUserId, timestamp); + consumePasswordResets(secondaryUserId, timestamp); + moveNotifications(primaryUserId, secondaryUserId); + moveNotificationPreferences( + primaryUserId, + secondaryUserId); + moveGovernanceNotifications( + primaryUserId, + secondaryUserId); + enqueueSessionRevocation( + secondaryUserId, + intentId, + timestamp); + } + + private List accounts( + String primaryUserId, + String secondaryUserId) { + return jdbcTemplate.query(""" + SELECT + id, + status, + merged_to_user_id, + system_account, + updated_at + FROM user_account + WHERE id IN (?, ?) + ORDER BY id + """, + (result, rowNumber) -> new AccountRow( + result.getString("id"), + result.getString("status"), + result.getString("merged_to_user_id"), + result.getBoolean("system_account"), + result.getTimestamp("updated_at")), + primaryUserId, + secondaryUserId); + } + + private List bindings( + String primaryUserId, + String secondaryUserId) { + return jdbcTemplate.query(""" + SELECT + id, + user_id, + provider_code, + status, + updated_at + FROM identity_binding + WHERE user_id IN (?, ?) + AND status = 'ACTIVE' + ORDER BY user_id, provider_code, id + """, + (result, rowNumber) -> new BindingRow( + result.getLong("id"), + result.getString("user_id"), + result.getString("provider_code"), + result.getString("status"), + result.getTimestamp("updated_at")), + primaryUserId, + secondaryUserId); + } + + private Set providers( + List bindings, + String userId) { + LinkedHashSet providers = new LinkedHashSet<>(); + bindings.stream() + .filter(row -> row.userId().equals(userId)) + .map(BindingRow::providerCode) + .sorted() + .forEach(providers::add); + return Set.copyOf(providers); + } + + private List credentials( + String primaryUserId, + String secondaryUserId) { + return jdbcTemplate.query(""" + SELECT + id, + user_id, + username, + failed_attempts, + locked_until, + updated_at + FROM local_credential + WHERE user_id IN (?, ?) + ORDER BY user_id, id + """, + (result, rowNumber) -> new CredentialRow( + result.getLong("id"), + result.getString("user_id"), + result.getString("username"), + result.getInt("failed_attempts"), + result.getTimestamp("locked_until"), + result.getTimestamp("updated_at")), + primaryUserId, + secondaryUserId); + } + + private LocalCredentialAction localCredentialAction( + boolean primary, + boolean secondary) { + if (!secondary) { + return LocalCredentialAction.NONE; + } + if (!primary) { + return LocalCredentialAction.MOVE_SECONDARY; + } + return LocalCredentialAction + .KEEP_PRIMARY_DELETE_SECONDARY; + } + + private List roles( + String primaryUserId, + String secondaryUserId) { + return jdbcTemplate.query(""" + SELECT binding.id, binding.user_id, role.code + FROM user_role_binding binding + JOIN role ON role.id = binding.role_id + WHERE binding.user_id IN (?, ?) + ORDER BY binding.user_id, role.code, binding.id + """, + (result, rowNumber) -> new RoleRow( + result.getLong("id"), + result.getString("user_id"), + result.getString("code")), + primaryUserId, + secondaryUserId); + } + + private List activeIdentityLinks( + String primaryUserId, + String secondaryUserId, + Instant now) { + return jdbcTemplate.query(""" + SELECT + id, + primary_user_id, + provider_code, + status, + expires_at, + updated_at + FROM identity_link_request + WHERE primary_user_id IN (?, ?) + AND status IN ( + 'PENDING_REAUTHENTICATION', + 'READY' + ) + AND expires_at > ? + ORDER BY primary_user_id, provider_code, id + """, + this::workflowRow, + primaryUserId, + secondaryUserId, + Timestamp.from(now)); + } + + private List memberships( + String primaryUserId, + String secondaryUserId) { + return jdbcTemplate.query(""" + SELECT + member.id, + member.user_id, + member.namespace_id, + namespace.slug, + member.role, + member.updated_at + FROM namespace_member member + JOIN namespace + ON namespace.id = member.namespace_id + WHERE member.user_id IN (?, ?) + ORDER BY + member.namespace_id, + member.user_id, + member.id + """, + (result, rowNumber) -> new MembershipRow( + result.getLong("id"), + result.getString("user_id"), + result.getLong("namespace_id"), + result.getString("slug"), + result.getString("role"), + result.getTimestamp("updated_at")), + primaryUserId, + secondaryUserId); + } + + private List namespaceChanges( + List rows, + String primaryUserId, + String secondaryUserId, + List conflicts) { + Map byNamespace = + new LinkedHashMap<>(); + for (MembershipRow row : rows) { + MembershipPair pair = byNamespace.computeIfAbsent( + row.namespaceId(), + ignored -> new MembershipPair( + row.namespaceId(), + row.namespaceSlug())); + if (row.userId().equals(primaryUserId)) { + pair.primary = row; + } else if (row.userId().equals( + secondaryUserId)) { + pair.secondary = row; + } + } + List changes = new ArrayList<>(); + for (MembershipPair pair : byNamespace.values()) { + if (pair.secondary == null) { + continue; + } + String primaryRole = pair.primary == null + ? null + : pair.primary.role(); + String secondaryRole = pair.secondary.role(); + String resultRole = strongerRole( + primaryRole, + secondaryRole); + boolean blocked = "OWNER".equals(resultRole) + && !"OWNER".equals(primaryRole); + if (blocked) { + conflicts.add(new Conflict( + ConflictCode.NAMESPACE_OWNER_CONFLICT, + pair.namespaceSlug)); + } + changes.add(new NamespaceChange( + pair.namespaceId, + pair.namespaceSlug, + primaryRole, + secondaryRole, + resultRole, + blocked)); + } + return List.copyOf(changes); + } + + private String strongerRole( + String first, + String second) { + if (first == null) { + return second; + } + return roleRank(first) >= roleRank(second) + ? first + : second; + } + + private int roleRank(String role) { + return switch (role) { + case "OWNER" -> 3; + case "ADMIN" -> 2; + case "MEMBER" -> 1; + default -> throw new IllegalStateException( + "Unknown namespace role " + role); + }; + } + + private List unrevokedTokens( + String secondaryUserId) { + return jdbcTemplate.query(""" + SELECT + id, + name, + token_prefix, + expires_at, + created_at + FROM api_token + WHERE user_id = ? + AND revoked_at IS NULL + ORDER BY name, id + """, + (result, rowNumber) -> new TokenRow( + result.getLong("id"), + result.getString("name"), + result.getString("token_prefix"), + result.getTimestamp("expires_at"), + result.getTimestamp("created_at")), + secondaryUserId); + } + + private List ownedSkills( + String primaryUserId, + String secondaryUserId) { + return jdbcTemplate.query(""" + SELECT + id, + owner_id, + namespace_id, + slug, + updated_at + FROM skill + WHERE owner_id IN (?, ?) + ORDER BY namespace_id, slug, owner_id, id + """, + (result, rowNumber) -> new SkillRow( + result.getLong("id"), + result.getString("owner_id"), + result.getLong("namespace_id"), + result.getString("slug"), + result.getTimestamp("updated_at")), + primaryUserId, + secondaryUserId); + } + + private List socialRows( + String table, + String primaryUserId, + String secondaryUserId, + boolean rating) { + String valueColumn = rating + ? ", score" + : ", NULL AS score"; + String updatedColumn = rating + ? ", updated_at" + : ", created_at AS updated_at"; + String sql = """ + SELECT id, user_id, skill_id + """ + + valueColumn + + updatedColumn + + " FROM " + + table + + """ + WHERE user_id IN (?, ?) + ORDER BY skill_id, user_id, id + """; + return jdbcTemplate.query( + sql, + (result, rowNumber) -> new SocialRow( + result.getLong("id"), + result.getString("user_id"), + result.getLong("skill_id"), + nullableInteger( + result.getObject("score")), + result.getTimestamp("updated_at")), + primaryUserId, + secondaryUserId); + } + + private Integer nullableInteger(Object value) { + return value == null + ? null + : ((Number) value).intValue(); + } + + private int duplicateCount( + List rows, + String primaryUserId, + String secondaryUserId) { + Set primarySkills = rows.stream() + .filter(row -> row.userId().equals( + primaryUserId)) + .map(SocialRow::skillId) + .collect(java.util.stream.Collectors.toSet()); + return (int) rows.stream() + .filter(row -> row.userId().equals( + secondaryUserId)) + .filter(row -> primarySkills.contains( + row.skillId())) + .count(); + } + + private int movedCount( + List rows, + String primaryUserId, + String secondaryUserId) { + int secondary = (int) rows.stream() + .filter(row -> row.userId().equals( + secondaryUserId)) + .count(); + return secondary - duplicateCount( + rows, + primaryUserId, + secondaryUserId); + } + + private List discardedRatings( + List ratings, + String primaryUserId, + String secondaryUserId) { + Set primarySkills = ratings.stream() + .filter(row -> row.userId().equals( + primaryUserId)) + .map(SocialRow::skillId) + .collect(java.util.stream.Collectors.toSet()); + return ratings.stream() + .filter(row -> row.userId().equals( + secondaryUserId)) + .filter(row -> primarySkills.contains( + row.skillId())) + .map(row -> new DiscardedRating( + row.skillId(), + java.util.Objects.requireNonNull( + row.score(), + "rating score"))) + .sorted(java.util.Comparator.comparingLong( + DiscardedRating::skillId)) + .toList(); + } + + private List pendingProfileChanges( + String secondaryUserId) { + return jdbcTemplate.query(""" + SELECT + id, + user_id, + 'profile' AS provider_code, + status, + created_at AS expires_at, + created_at AS updated_at + FROM profile_change_request + WHERE user_id = ? + AND status = 'PENDING' + ORDER BY id + """, + this::workflowRow, + secondaryUserId); + } + + private List passwordResets( + String secondaryUserId) { + return transientRows( + """ + SELECT id, created_at + FROM password_reset_request + WHERE user_id = ? + AND consumed_at IS NULL + ORDER BY id + """, + secondaryUserId); + } + + private List notifications( + String secondaryUserId) { + return transientRows( + """ + SELECT id, created_at + FROM notification + WHERE recipient_id = ? + ORDER BY id + """, + secondaryUserId); + } + + private List preferences( + String primaryUserId, + String secondaryUserId) { + return jdbcTemplate.query(""" + SELECT id, user_id, category, channel, enabled + FROM notification_preference + WHERE user_id IN (?, ?) + ORDER BY category, channel, user_id, id + """, + (result, rowNumber) -> new PreferenceRow( + result.getLong("id"), + result.getString("user_id"), + result.getString("category"), + result.getString("channel"), + result.getBoolean("enabled")), + primaryUserId, + secondaryUserId); + } + + private int duplicatePreferences( + List preferences, + String primaryUserId, + String secondaryUserId) { + Set primary = preferences.stream() + .filter(row -> row.userId().equals( + primaryUserId)) + .map(row -> row.category() + + "\u0000" + + row.channel()) + .collect(java.util.stream.Collectors.toSet()); + return (int) preferences.stream() + .filter(row -> row.userId().equals( + secondaryUserId)) + .filter(row -> primary.contains( + row.category() + + "\u0000" + + row.channel())) + .count(); + } + + private List governanceNotifications( + String secondaryUserId) { + return transientRows( + """ + SELECT id, created_at + FROM user_notification + WHERE user_id = ? + ORDER BY id + """, + secondaryUserId); + } + + private List transientRows( + String sql, + String userId) { + return jdbcTemplate.query( + sql, + (result, rowNumber) -> new TransientRow( + result.getLong("id"), + result.getTimestamp("created_at")), + userId); + } + + private WorkflowRow workflowRow( + ResultSet result, + int rowNumber) throws SQLException { + return new WorkflowRow( + result.getString("id"), + result.getString(2), + result.getString("provider_code"), + result.getString("status"), + result.getTimestamp("expires_at"), + result.getTimestamp("updated_at")); + } + + private void moveBindings( + String primaryUserId, + String secondaryUserId, + Timestamp now) { + jdbcTemplate.update(""" + UPDATE identity_binding + SET user_id = ?, updated_at = ? + WHERE user_id = ? + AND status = 'ACTIVE' + """, + primaryUserId, + now, + secondaryUserId); + } + + private void moveCredential( + String primaryUserId, + String secondaryUserId, + LocalCredentialAction action, + Timestamp now) { + switch (action) { + case NONE -> { + } + case MOVE_SECONDARY -> jdbcTemplate.update(""" + UPDATE local_credential + SET user_id = ?, updated_at = ? + WHERE user_id = ? + """, + primaryUserId, + now, + secondaryUserId); + case KEEP_PRIMARY_DELETE_SECONDARY -> + jdbcTemplate.update(""" + DELETE FROM local_credential + WHERE user_id = ? + """, + secondaryUserId); + } + } + + private void moveMemberships( + String primaryUserId, + String secondaryUserId, + List changes, + Timestamp now) { + for (NamespaceChange change : changes) { + if (change.blocked()) { + throw new IllegalArgumentException( + "Blocked namespace change cannot be applied"); + } + if (change.primaryRole() == null) { + jdbcTemplate.update(""" + UPDATE namespace_member + SET user_id = ?, updated_at = ? + WHERE namespace_id = ? + AND user_id = ? + """, + primaryUserId, + now, + change.namespaceId(), + secondaryUserId); + continue; + } + jdbcTemplate.update(""" + UPDATE namespace_member + SET role = ?, updated_at = ? + WHERE namespace_id = ? + AND user_id = ? + """, + change.resultingRole(), + now, + change.namespaceId(), + primaryUserId); + jdbcTemplate.update(""" + DELETE FROM namespace_member + WHERE namespace_id = ? + AND user_id = ? + """, + change.namespaceId(), + secondaryUserId); + } + } + + private void moveSkills( + String primaryUserId, + String secondaryUserId, + Timestamp now) { + jdbcTemplate.update(""" + UPDATE skill_search_document + SET owner_id = ?, updated_at = ? + WHERE owner_id = ? + """, + primaryUserId, + now, + secondaryUserId); + jdbcTemplate.update(""" + UPDATE skill + SET owner_id = ?, updated_at = ? + WHERE owner_id = ? + """, + primaryUserId, + now, + secondaryUserId); + } + + private void moveSocialState( + String table, + String counterColumn, + String primaryUserId, + String secondaryUserId) { + List affected = affectedSkills( + table, + primaryUserId, + secondaryUserId); + String deleteSql = "DELETE FROM " + + table + + " secondary_state WHERE secondary_state.user_id = ?" + + " AND EXISTS (SELECT 1 FROM " + + table + + " primary_state WHERE primary_state.skill_id" + + " = secondary_state.skill_id" + + " AND primary_state.user_id = ?)"; + jdbcTemplate.update( + deleteSql, + secondaryUserId, + primaryUserId); + jdbcTemplate.update( + "UPDATE " + table + + " SET user_id = ? WHERE user_id = ?", + primaryUserId, + secondaryUserId); + for (Long skillId : affected) { + jdbcTemplate.update( + "UPDATE skill SET " + + counterColumn + + " = (SELECT COUNT(*) FROM " + + table + + " WHERE skill_id = ?)" + + " WHERE id = ?", + skillId, + skillId); + } + } + + private void moveRatings( + String primaryUserId, + String secondaryUserId) { + List affected = affectedSkills( + "skill_rating", + primaryUserId, + secondaryUserId); + jdbcTemplate.update(""" + DELETE FROM skill_rating secondary_rating + WHERE secondary_rating.user_id = ? + AND EXISTS ( + SELECT 1 + FROM skill_rating primary_rating + WHERE primary_rating.skill_id = + secondary_rating.skill_id + AND primary_rating.user_id = ? + ) + """, + secondaryUserId, + primaryUserId); + jdbcTemplate.update(""" + UPDATE skill_rating + SET user_id = ? + WHERE user_id = ? + """, + primaryUserId, + secondaryUserId); + for (Long skillId : affected) { + jdbcTemplate.update(""" + UPDATE skill + SET + rating_count = ( + SELECT COUNT(*) + FROM skill_rating + WHERE skill_id = ? + ), + rating_avg = COALESCE(( + SELECT AVG(score) + FROM skill_rating + WHERE skill_id = ? + ), 0) + WHERE id = ? + """, + skillId, + skillId, + skillId); + } + } + + private List affectedSkills( + String table, + String primaryUserId, + String secondaryUserId) { + return jdbcTemplate.query( + "SELECT DISTINCT skill_id FROM " + + table + + " WHERE user_id IN (?, ?)" + + " ORDER BY skill_id", + (result, rowNumber) -> + result.getLong("skill_id"), + primaryUserId, + secondaryUserId); + } + + private void revokeTokens( + String secondaryUserId, + Timestamp now) { + jdbcTemplate.update(""" + UPDATE api_token + SET revoked_at = ? + WHERE user_id = ? + AND revoked_at IS NULL + """, + now, + secondaryUserId); + } + + private void consumePasswordResets( + String secondaryUserId, + Timestamp now) { + jdbcTemplate.update(""" + UPDATE password_reset_request + SET consumed_at = ? + WHERE user_id = ? + AND consumed_at IS NULL + """, + now, + secondaryUserId); + } + + private void moveNotifications( + String primaryUserId, + String secondaryUserId) { + jdbcTemplate.update(""" + UPDATE notification + SET recipient_id = ? + WHERE recipient_id = ? + """, + primaryUserId, + secondaryUserId); + } + + private void moveNotificationPreferences( + String primaryUserId, + String secondaryUserId) { + jdbcTemplate.update(""" + DELETE FROM notification_preference secondary_pref + WHERE secondary_pref.user_id = ? + AND EXISTS ( + SELECT 1 + FROM notification_preference primary_pref + WHERE primary_pref.user_id = ? + AND primary_pref.category = + secondary_pref.category + AND primary_pref.channel = + secondary_pref.channel + ) + """, + secondaryUserId, + primaryUserId); + jdbcTemplate.update(""" + UPDATE notification_preference + SET user_id = ? + WHERE user_id = ? + """, + primaryUserId, + secondaryUserId); + } + + private void moveGovernanceNotifications( + String primaryUserId, + String secondaryUserId) { + jdbcTemplate.update(""" + UPDATE user_notification + SET user_id = ? + WHERE user_id = ? + """, + primaryUserId, + secondaryUserId); + } + + private void enqueueSessionRevocation( + String secondaryUserId, + UUID intentId, + Timestamp now) { + jdbcTemplate.update(""" + INSERT INTO account_merge_session_revocation ( + merge_intent_id, + user_id, + status, + attempt_count, + next_attempt_at, + created_at, + updated_at + ) VALUES (?, ?, 'PENDING', 0, ?, ?, ?) + """, + intentId, + secondaryUserId, + now, + now, + now); + } + + private record AccountRow( + String id, + String status, + String mergedToUserId, + boolean systemAccount, + Timestamp updatedAt) { + } + + private record BindingRow( + long id, + String userId, + String providerCode, + String status, + Timestamp updatedAt) { + } + + private record CredentialRow( + long id, + String userId, + String username, + int failedAttempts, + Timestamp lockedUntil, + Timestamp updatedAt) { + } + + private record RoleRow( + long id, + String userId, + String roleCode) { + } + + private record WorkflowRow( + String id, + String userId, + String providerCode, + String status, + Timestamp expiresAt, + Timestamp updatedAt) { + } + + private record MembershipRow( + long id, + String userId, + long namespaceId, + String namespaceSlug, + String role, + Timestamp updatedAt) { + } + + private record TokenRow( + long id, + String name, + String prefix, + Timestamp expiresAt, + Timestamp createdAt) { + } + + private record SkillRow( + long id, + String userId, + long namespaceId, + String slug, + Timestamp updatedAt) { + + private String coordinate() { + return namespaceId + "/" + slug; + } + } + + private record SocialRow( + long id, + String userId, + long skillId, + Integer score, + Timestamp updatedAt) { + } + + private record TransientRow( + long id, + Timestamp createdAt) { + } + + private record PreferenceRow( + long id, + String userId, + String category, + String channel, + boolean enabled) { + } + + private static final class MembershipPair { + private final long namespaceId; + private final String namespaceSlug; + private MembershipRow primary; + private MembershipRow secondary; + + private MembershipPair( + long namespaceId, + String namespaceSlug) { + this.namespaceId = namespaceId; + this.namespaceSlug = namespaceSlug; + } + } + + private static final class SnapshotDigest { + private final MessageDigest digest; + + private SnapshotDigest() { + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException( + "SHA-256 is unavailable", + exception); + } + } + + private void rows(String section, List rows) { + value(section); + value(rows.size()); + rows.forEach(this::value); + } + + private void value(Object value) { + String text = value == null + ? "" + : value.toString(); + byte[] bytes = text.getBytes( + StandardCharsets.UTF_8); + digest.update(Integer.toString(bytes.length) + .getBytes(StandardCharsets.US_ASCII)); + digest.update((byte) ':'); + digest.update(bytes); + digest.update((byte) '\n'); + } + + private String finish() { + return HexFormat.of().formatHex(digest.digest()); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AccountMergeSessionRevocationRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AccountMergeSessionRevocationRepository.java new file mode 100644 index 00000000..8d04ff21 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AccountMergeSessionRevocationRepository.java @@ -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. + * + *

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 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 + ) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AccountMergeAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AccountMergeAppService.java new file mode 100644 index 00000000..0d2afd3b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AccountMergeAppService.java @@ -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 + primaryMethods(String userId) { + IdentityLinkAccountState state = + identityLinkIntentService.accountState(userId); + List 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 + secondaryMethods() { + List 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 + sortedDistinct( + List + methods) { + Map + 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 + 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); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java index 67986c7d..c4689c6d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/CasLoginAppService.java @@ -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 accountMergeFlow; + try { + accountMergeFlow = + accountMergeSessionManager.consumeBrowserFlow( + request, + providerCode, + context); + } catch (AccountMergeException exception) { + return accountMergeFailureTarget( + loginState.returnTo(), + exception.getReasonCode()); + } Optional 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 + 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 consumePreparedFlowFailure( + HttpSession session, + AccountMergeFailureCode accountMergeReason, + IdentityLinkFailureCode identityLinkReason) { + Optional + 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 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) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java index e62bbe6d..cc4595a8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java @@ -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, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapper.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapper.java index d72d6602..8fcacdce 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapper.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ProviderAuthenticationFailureMapper.java @@ -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) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/AccountMergeIntentCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/AccountMergeIntentCleanupTask.java new file mode 100644 index 00000000..f26eda7e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/AccountMergeIntentCleanupTask.java @@ -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); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/AccountMergeSessionRevocationTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/AccountMergeSessionRevocationTask.java new file mode 100644 index 00000000..fb9883b1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/AccountMergeSessionRevocationTask.java @@ -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 + sessionRepository; + private final SseEmitterManager sseEmitterManager; + private final AccountMergeMetrics metrics; + private final AuditLogService auditLogService; + private final Clock clock; + + public AccountMergeSessionRevocationTask( + AccountMergeSessionRevocationRepository repository, + FindByIndexNameSessionRepository + 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 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); + } + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 69812bf8..feb3af65 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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.. # Defaults: AUTO provisioning; PRESERVE_LOCAL displayName/avatarUrl; diff --git a/server/skillhub-app/src/main/resources/db/migration/V50__account_merge_intent.sql b/server/skillhub-app/src/main/resources/db/migration/V50__account_merge_intent.sql new file mode 100644 index 00000000..9172366f --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V50__account_merge_intent.sql @@ -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'); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 9c868c98..8c72358a 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 079eac2a..4994eea0 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkMigrationLoginPostgresIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkMigrationLoginPostgresIntegrationTest.java index e6edac50..7c43efc0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkMigrationLoginPostgresIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkMigrationLoginPostgresIntegrationTest.java @@ -255,7 +255,6 @@ class IdentityLinkMigrationLoginPostgresIntegrationTest { .schemas(SCHEMA) .defaultSchema(SCHEMA) .createSchemas(true) - .target(MigrationVersion.fromVersion("49")) .load() .migrate(); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentMigrationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentMigrationPostgresTest.java new file mode 100644 index 00000000..15e5f4fd --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentMigrationPostgresTest.java @@ -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"); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergePostgresIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergePostgresIntegrationTest.java new file mode 100644 index 00000000..e50247ee --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergePostgresIntegrationTest.java @@ -0,0 +1,1410 @@ +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.merge.AccountMergeDataGateway; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +@SpringBootTest +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@EnabledIfEnvironmentVariable( + named = "IDENTITY_BINDING_V2_POSTGRES_URL", + matches = "jdbc:postgresql:.*") +class AccountMergePostgresIntegrationTest { + + private static final String SCHEMA = + "account_merge_pr662_integration"; + + @Autowired + private AccountMergeIntentService intentService; + + @Autowired + private AccountMergeDataGateway dataGateway; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PlatformTransactionManager transactionManager; + + @MockBean + @SuppressWarnings("rawtypes") + private FindByIndexNameSessionRepository sessionRepository; + + @DynamicPropertySource + static void postgresProperties( + DynamicPropertyRegistry registry) { + String url = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_URL"); + String username = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_USERNAME"); + String password = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_PASSWORD"); + createSchema(url, username, password); + registry.add( + "spring.datasource.url", + () -> withCurrentSchema(url)); + registry.add( + "spring.datasource.username", + () -> username); + registry.add( + "spring.datasource.password", + () -> password); + registry.add( + "spring.datasource.driver-class-name", + () -> "org.postgresql.Driver"); + registry.add( + "spring.jpa.database-platform", + () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add( + "spring.jpa.hibernate.ddl-auto", + () -> "validate"); + registry.add("spring.flyway.enabled", () -> "true"); + registry.add( + "spring.flyway.default-schema", + () -> SCHEMA); + registry.add("spring.flyway.schemas", () -> SCHEMA); + registry.add( + "skillhub.builtin-skills.enabled", + () -> "false"); + registry.add( + "skillhub.auth.account-merge.enabled", + () -> "true"); + registry.add( + "skillhub.auth.account-merge." + + "session-cutover-complete", + () -> "true"); + registry.add( + "skillhub.auth.account-merge." + + "session-revocation.poll-interval-ms", + () -> "3600000"); + } + + @AfterAll + static void dropSchema() { + String url = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_URL"); + String username = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_USERNAME"); + String password = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_PASSWORD"); + try (Connection connection = + DriverManager.getConnection( + url, + username, + password); + Statement statement = + connection.createStatement()) { + statement.execute( + "DROP SCHEMA IF EXISTS " + + SCHEMA + + " CASCADE"); + } catch (Exception exception) { + throw new IllegalStateException( + "Failed to remove account merge test schema", + exception); + } + } + + @Test + void confirmMigratesCurrentStateRevokesCredentialsAndPreservesHistory() { + String primary = "merge-full-primary"; + String secondary = "merge-full-secondary"; + seedUsers(primary, secondary); + SeededResources resources = + seedCompleteMergeState(primary, secondary); + PreparedMerge merge = prepareMerge( + primary, + secondary, + "merge-full"); + + AccountMergeCompletion completion = + intentService.confirm( + merge.actor(), + merge.intentId(), + merge.previewVersion()); + + assertThat(completion.status()) + .isEqualTo(AccountMergeIntentStatus.COMPLETED); + assertThat(singleString( + "SELECT status FROM user_account WHERE id = ?", + secondary)).isEqualTo("MERGED"); + assertThat(singleString( + """ + SELECT merged_to_user_id + FROM user_account + WHERE id = ? + """, + secondary)).isEqualTo(primary); + + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding + WHERE user_id = ? + AND provider_code = 'github' + AND status = 'ACTIVE' + """, + primary)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding + WHERE user_id = ? + AND provider_code = 'legacy-revoked' + AND status = 'REVOKED' + AND revoked_by = ? + """, + secondary, + secondary)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM local_credential + WHERE user_id = ? + """, + primary)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM local_credential + WHERE user_id = ? + """, + secondary)).isZero(); + + assertThat(singleString( + """ + SELECT role + FROM namespace_member + WHERE namespace_id = ? + AND user_id = ? + """, + resources.sharedNamespaceId(), + primary)).isEqualTo("ADMIN"); + assertThat(count( + """ + SELECT COUNT(*) + FROM namespace_member + WHERE namespace_id = ? + AND user_id = ? + """, + resources.secondaryNamespaceId(), + primary)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM namespace_member + WHERE user_id = ? + """, + secondary)).isZero(); + + assertThat(count( + "SELECT COUNT(*) FROM skill WHERE owner_id = ?", + secondary)).isZero(); + assertThat(count( + """ + SELECT COUNT(*) + FROM skill_search_document + WHERE skill_id = ? + AND owner_id = ? + """, + resources.secondarySkillId(), + primary)).isEqualTo(1L); + + assertThat(count( + """ + SELECT COUNT(*) + FROM skill_star + WHERE skill_id = ? + """, + resources.sharedSkillId())).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM skill_star + WHERE skill_id = ? + AND user_id = ? + """, + resources.secondarySkillId(), + primary)).isEqualTo(1L); + assertThat(singleLong( + "SELECT star_count FROM skill WHERE id = ?", + resources.sharedSkillId())).isEqualTo(1L); + assertThat(singleLong( + """ + SELECT subscription_count + FROM skill + WHERE id = ? + """, + resources.sharedSkillId())).isEqualTo(1L); + assertThat(singleLong( + "SELECT rating_count FROM skill WHERE id = ?", + resources.sharedSkillId())).isEqualTo(1L); + assertThat(singleString( + "SELECT rating_avg::text FROM skill WHERE id = ?", + resources.sharedSkillId())).isEqualTo("5.00"); + + assertThat(count( + """ + SELECT COUNT(*) + FROM api_token + WHERE user_id = ? + AND subject_id = ? + AND revoked_at IS NOT NULL + """, + secondary, + secondary)).isEqualTo(2L); + assertThat(count( + """ + SELECT COUNT(*) + FROM password_reset_request + WHERE user_id = ? + AND consumed_at IS NOT NULL + """, + secondary)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM notification + WHERE recipient_id = ? + """, + primary)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM notification_preference + WHERE user_id = ? + """, + primary)).isEqualTo(2L); + assertThat(count( + """ + SELECT COUNT(*) + FROM notification_preference + WHERE user_id = ? + """, + secondary)).isZero(); + assertThat(count( + """ + SELECT COUNT(*) + FROM user_notification + WHERE user_id = ? + """, + primary)).isEqualTo(1L); + + assertThat(count( + """ + SELECT COUNT(*) + FROM audit_log + WHERE actor_user_id = ? + AND action = 'HISTORICAL_ACTION' + """, + secondary)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM account_merge_session_revocation + WHERE merge_intent_id = ? + AND user_id = ? + AND status = 'PENDING' + """, + merge.intentId(), + secondary)).isEqualTo(1L); + } + + @Test + void expiredUnrevokedTokenMakesPreviewStaleWithoutPartialMigration() { + String primary = "merge-stale-primary"; + String secondary = "merge-stale-secondary"; + seedUsers(primary, secondary); + PreparedMerge merge = prepareMerge( + primary, + secondary, + "merge-stale"); + insertToken( + secondary, + "created-after-preview", + "stale001", + "c"); + jdbcTemplate.update(""" + UPDATE api_token + SET expires_at = + CURRENT_TIMESTAMP - INTERVAL '1 minute' + WHERE user_id = ? + AND name = 'created-after-preview' + """, + secondary); + + assertThatThrownBy(() -> + intentService.confirm( + merge.actor(), + merge.intentId(), + merge.previewVersion())) + .isInstanceOfSatisfying( + AccountMergeException.class, + exception -> assertThat( + exception.getReasonCode()) + .isEqualTo( + AccountMergeFailureCode + .MERGE_PREVIEW_STALE)); + + assertThat(singleString( + "SELECT status FROM user_account WHERE id = ?", + secondary)).isEqualTo("ACTIVE"); + assertThat(count( + """ + SELECT COUNT(*) + FROM api_token + WHERE user_id = ? + AND revoked_at IS NULL + """, + secondary)).isEqualTo(1L); + assertThat(singleString( + """ + SELECT status + FROM account_merge_intent + WHERE id = ? + """, + merge.intentId())) + .isEqualTo("READY_FOR_PREVIEW"); + } + + @Test + void concurrentConfirmationCommitsExactlyOnce() throws Exception { + String primary = "merge-race-primary"; + String secondary = "merge-race-secondary"; + seedUsers(primary, secondary); + PreparedMerge merge = prepareMerge( + primary, + secondary, + "merge-race"); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + try (var executor = + java.util.concurrent.Executors + .newVirtualThreadPerTaskExecutor()) { + for (int index = 0; index < 2; index++) { + futures.add(executor.submit(() -> { + start.await(); + try { + return intentService.confirm( + merge.actor(), + merge.intentId(), + merge.previewVersion()); + } catch (Throwable failure) { + return failure; + } + })); + } + start.countDown(); + List outcomes = new ArrayList<>(); + for (Future future : futures) { + outcomes.add(future.get()); + } + + assertThat(outcomes.stream() + .filter(AccountMergeCompletion.class::isInstance) + .count()).isEqualTo(1L); + assertThat(outcomes.stream() + .filter(AccountMergeException.class::isInstance) + .map(AccountMergeException.class::cast) + .map(AccountMergeException::getReasonCode)) + .containsExactly( + AccountMergeFailureCode + .MERGE_ALREADY_CONSUMED); + } + + assertThat(count( + """ + SELECT COUNT(*) + FROM account_merge_session_revocation + WHERE merge_intent_id = ? + """, + merge.intentId())).isEqualTo(1L); + } + + @Test + void failureAtTheFinalRepositoryStepRollsBackAllEarlierMoves() { + String primary = "merge-rollback-primary"; + String secondary = "merge-rollback-secondary"; + seedUsers(primary, secondary); + insertCredentials(primary, secondary); + insertToken( + secondary, + "rollback-token", + "rollback", + "d"); + PreparedMerge merge = prepareMerge( + primary, + secondary, + "merge-rollback"); + installSessionTaskFailureTrigger(); + try { + assertThatThrownBy(() -> + intentService.confirm( + merge.actor(), + merge.intentId(), + merge.previewVersion())) + .isInstanceOf(RuntimeException.class); + } finally { + removeSessionTaskFailureTrigger(); + } + + assertThat(singleString( + "SELECT status FROM user_account WHERE id = ?", + secondary)).isEqualTo("ACTIVE"); + assertThat(count( + """ + SELECT COUNT(*) + FROM local_credential + WHERE user_id = ? + """, + secondary)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM api_token + WHERE user_id = ? + AND revoked_at IS NULL + """, + secondary)).isEqualTo(1L); + assertThat(singleString( + """ + SELECT status + FROM account_merge_intent + WHERE id = ? + """, + merge.intentId())) + .isEqualTo("READY_TO_CONFIRM"); + assertThat(count( + """ + SELECT COUNT(*) + FROM account_merge_session_revocation + WHERE merge_intent_id = ? + """, + merge.intentId())).isZero(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("atomicFailureStages") + void failureAtEveryRequiredStageRollsBackTheWholeMerge( + FailureStage stage) { + String primary = + "merge-atomic-" + stage.suffix + "-primary"; + String secondary = + "merge-atomic-" + stage.suffix + "-secondary"; + seedUsers(primary, secondary); + seedCompleteMergeState(primary, secondary); + PreparedMerge merge = prepareMerge( + primary, + secondary, + "merge-atomic-" + stage.suffix); + String previewDigest = singleString( + """ + SELECT preview_digest + FROM account_merge_intent + WHERE id = ? + """, + merge.intentId()); + installFailureTrigger(stage); + try { + assertThatThrownBy(() -> + intentService.confirm( + merge.actor(), + merge.intentId(), + merge.previewVersion())) + .isInstanceOf(RuntimeException.class); + } finally { + removeFailureTrigger(stage); + } + + assertThat(singleString( + "SELECT status FROM user_account WHERE id = ?", + secondary)).isEqualTo("ACTIVE"); + assertThat(singleString( + """ + SELECT status + FROM account_merge_intent + WHERE id = ? + """, + merge.intentId())).isEqualTo( + "READY_TO_CONFIRM"); + assertThat(dataGateway.inspect( + primary, + secondary, + Instant.now()).digest()).isEqualTo( + previewDigest); + assertThat(count( + """ + SELECT COUNT(*) + FROM account_merge_session_revocation + WHERE merge_intent_id = ? + """, + merge.intentId())).isZero(); + assertThat(count( + """ + SELECT COUNT(*) + FROM audit_log + WHERE actor_user_id = ? + AND action IN ( + 'ACCOUNT_MERGE_CONFIRMED', + 'ACCOUNT_MERGE_COMPLETED' + ) + """, + primary)).isZero(); + } + + @Test + void previewReportsEveryBlockingConflictBeforeAnyMigration() { + String primary = "merge-conflict-primary"; + String secondary = "merge-conflict-secondary"; + seedUsers(primary, secondary); + insertActiveBinding( + primary, + "conflict-provider", + "conflict-primary-subject"); + insertActiveBinding( + secondary, + "conflict-provider", + "conflict-secondary-subject"); + jdbcTemplate.update(""" + INSERT INTO user_role_binding ( + user_id, + role_id + ) + SELECT ?, id + FROM role + WHERE code = 'AUDITOR' + """, + secondary); + long namespaceId = insertNamespace( + "merge-conflict-namespace", + primary); + jdbcTemplate.update(""" + INSERT INTO namespace_member ( + namespace_id, + user_id, + role + ) VALUES (?, ?, 'OWNER') + """, + namespaceId, + secondary); + insertSkill( + namespaceId, + "same-coordinate", + primary); + insertSkill( + namespaceId, + "same-coordinate", + secondary); + jdbcTemplate.update(""" + INSERT INTO identity_link_request ( + id, + primary_user_id, + operation, + provider_code, + state_hash, + status, + expires_at + ) VALUES ( + ?, + ?, + 'LINK', + 'pending-provider', + repeat('e', 64), + 'PENDING_REAUTHENTICATION', + CURRENT_TIMESTAMP + INTERVAL '10 minutes' + ) + """, + UUID.randomUUID(), + secondary); + jdbcTemplate.update(""" + INSERT INTO profile_change_request ( + user_id, + changes, + status + ) VALUES ( + ?, + '{"displayName":"Pending"}'::jsonb, + 'PENDING' + ) + """, + secondary); + + AccountMergeActor actor = actor( + primary, + "merge-conflict"); + UUID intentId = UUID.randomUUID(); + intentService.createIntent(actor, intentId); + intentService.recordSecondaryProof( + actor, + intentId, + secondary, + "local-password"); + AccountMergePreview preview = + intentService.preview(actor, intentId); + + assertThat(preview.status()).isEqualTo( + AccountMergeIntentStatus.FAILED_CONFLICT); + assertThat(preview.plan().confirmable()).isFalse(); + assertThat(preview.plan().conflicts()) + .extracting(AccountMergePlan.Conflict::code) + .containsExactlyInAnyOrder( + AccountMergePlan.ConflictCode + .IDENTITY_PROVIDER_CONFLICT, + AccountMergePlan.ConflictCode + .PLATFORM_ROLE_CONFLICT, + AccountMergePlan.ConflictCode + .NAMESPACE_OWNER_CONFLICT, + AccountMergePlan.ConflictCode + .SKILL_OWNERSHIP_CONFLICT, + AccountMergePlan.ConflictCode + .ACTIVE_IDENTITY_LINK, + AccountMergePlan.ConflictCode + .PENDING_PROFILE_CHANGE); + assertThatThrownBy(() -> + intentService.confirm( + actor, + intentId, + preview.previewVersion())) + .isInstanceOfSatisfying( + AccountMergeException.class, + exception -> assertThat( + exception.getReasonCode()) + .isEqualTo( + AccountMergeFailureCode + .MERGE_PREVIEW_STALE)); + assertThat(singleString( + "SELECT status FROM user_account WHERE id = ?", + secondary)).isEqualTo("ACTIVE"); + } + + private PreparedMerge prepareMerge( + String primary, + String secondary, + String noncePrefix) { + AccountMergeActor actor = actor( + primary, + noncePrefix); + UUID intentId = UUID.randomUUID(); + intentService.createIntent(actor, intentId); + intentService.recordSecondaryProof( + actor, + intentId, + secondary, + "local-password"); + AccountMergePreview preview = + intentService.preview(actor, intentId); + assertThat(preview.plan().confirmable()).isTrue(); + return new PreparedMerge( + actor, + intentId, + preview.previewVersion()); + } + + private AccountMergeActor actor( + String primary, + String noncePrefix) { + return new AccountMergeActor( + primary, + "local", + noncePrefix + "-high-entropy-session-nonce", + "local-password", + Instant.now().minusSeconds(1), + new IdentityLoginContext( + "req-" + noncePrefix, + "203.0.113.20", + "Account Merge PostgreSQL Test")); + } + + private SeededResources seedCompleteMergeState( + String primary, + String secondary) { + insertCredentials(primary, secondary); + insertBindings(secondary); + + long sharedNamespace = insertNamespace( + primary + "-shared", + primary); + long secondaryNamespace = insertNamespace( + primary + "-secondary-only", + primary); + jdbcTemplate.update(""" + INSERT INTO namespace_member ( + namespace_id, + user_id, + role + ) VALUES + (?, ?, 'MEMBER'), + (?, ?, 'ADMIN'), + (?, ?, 'MEMBER') + """, + sharedNamespace, + primary, + sharedNamespace, + secondary, + secondaryNamespace, + secondary); + + long sharedSkill = insertSkill( + sharedNamespace, + "shared-skill", + primary); + long secondarySkill = insertSkill( + sharedNamespace, + "secondary-skill", + secondary); + insertSearchDocument( + secondarySkill, + sharedNamespace, + primary + "-shared", + secondary); + + jdbcTemplate.update(""" + INSERT INTO skill_star (skill_id, user_id) + VALUES + (?, ?), + (?, ?), + (?, ?) + """, + sharedSkill, + primary, + sharedSkill, + secondary, + secondarySkill, + secondary); + jdbcTemplate.update(""" + INSERT INTO skill_rating ( + skill_id, + user_id, + score + ) VALUES + (?, ?, 5), + (?, ?, 1), + (?, ?, 3) + """, + sharedSkill, + primary, + sharedSkill, + secondary, + secondarySkill, + secondary); + jdbcTemplate.update(""" + INSERT INTO skill_subscription ( + skill_id, + user_id + ) VALUES + (?, ?), + (?, ?), + (?, ?) + """, + sharedSkill, + primary, + sharedSkill, + secondary, + secondarySkill, + secondary); + jdbcTemplate.update(""" + UPDATE skill + SET + star_count = CASE + WHEN id = ? THEN 2 + ELSE 1 + END, + rating_count = CASE + WHEN id = ? THEN 2 + ELSE 1 + END, + rating_avg = CASE + WHEN id = ? THEN 3.00 + ELSE 3.00 + END, + subscription_count = CASE + WHEN id = ? THEN 2 + ELSE 1 + END + WHERE id IN (?, ?) + """, + sharedSkill, + sharedSkill, + sharedSkill, + sharedSkill, + sharedSkill, + secondarySkill); + + insertToken( + secondary, + "active-secondary-token", + "active01", + "a"); + insertToken( + secondary, + "already-revoked-token", + "revoked1", + "b"); + jdbcTemplate.update(""" + UPDATE api_token + SET revoked_at = CURRENT_TIMESTAMP + WHERE user_id = ? + AND name = 'already-revoked-token' + """, + secondary); + jdbcTemplate.update(""" + INSERT INTO password_reset_request ( + user_id, + email, + code_hash, + expires_at + ) VALUES ( + ?, + 'secondary@example.com', + 'reset-hash', + CURRENT_TIMESTAMP + INTERVAL '10 minutes' + ) + """, + secondary); + jdbcTemplate.update(""" + INSERT INTO notification ( + recipient_id, + category, + event_type, + title + ) VALUES (?, 'SKILL', 'UPDATED', 'Updated') + """, + secondary); + jdbcTemplate.update(""" + INSERT INTO notification_preference ( + user_id, + category, + channel, + enabled + ) VALUES + (?, 'SKILL', 'IN_APP', TRUE), + (?, 'SKILL', 'IN_APP', FALSE), + (?, 'SECURITY', 'IN_APP', TRUE) + """, + primary, + secondary, + secondary); + jdbcTemplate.update(""" + INSERT INTO user_notification ( + user_id, + category, + entity_type, + entity_id, + title + ) VALUES ( + ?, + 'GOVERNANCE', + 'SKILL', + ?, + 'Governance' + ) + """, + secondary, + secondarySkill); + jdbcTemplate.update(""" + INSERT INTO audit_log ( + actor_user_id, + action, + target_type, + detail_json + ) VALUES ( + ?, + 'HISTORICAL_ACTION', + 'USER', + '{}'::jsonb + ) + """, + secondary); + return new SeededResources( + sharedNamespace, + secondaryNamespace, + sharedSkill, + secondarySkill); + } + + private void seedUsers( + String primary, + String secondary) { + jdbcTemplate.update(""" + INSERT INTO user_account ( + id, + display_name, + status + ) VALUES + (?, 'Primary', 'ACTIVE'), + (?, 'Secondary', 'ACTIVE') + """, + primary, + secondary); + } + + private void insertCredentials( + String primary, + String secondary) { + jdbcTemplate.update(""" + INSERT INTO local_credential ( + user_id, + username, + password_hash + ) VALUES + (?, ?, 'primary-hash'), + (?, ?, 'secondary-hash') + """, + primary, + primary + "-login", + secondary, + secondary + "-login"); + } + + private void insertBindings(String secondary) { + insertActiveBinding( + secondary, + "github", + secondary + "-github-subject"); + new TransactionTemplate(transactionManager) + .executeWithoutResult(ignored -> { + jdbcTemplate.update(""" + INSERT INTO identity_binding ( + user_id, + provider_code, + subject, + login_name, + status, + revoked_at, + revoked_by, + revocation_reason + ) VALUES ( + ?, + 'legacy-revoked', + ?, + 'secondary-revoked', + 'REVOKED', + CURRENT_TIMESTAMP, + ?, + 'historical' + ) + """, + secondary, + secondary + "-revoked-subject", + secondary); + }); + } + + private void insertActiveBinding( + String userId, + String providerCode, + String subject) { + new TransactionTemplate(transactionManager) + .executeWithoutResult(ignored -> { + Long bindingId = jdbcTemplate.queryForObject( + """ + INSERT INTO identity_binding ( + user_id, + provider_code, + subject, + login_name, + status + ) VALUES ( + ?, + ?, + ?, + ?, + 'ACTIVE' + ) + RETURNING id + """, + Long.class, + userId, + providerCode, + subject, + userId + "-login"); + jdbcTemplate.update(""" + INSERT INTO identity_binding_subject ( + binding_id, + provider_code, + subject_type, + subject_value, + is_primary, + status + ) VALUES ( + ?, + ?, + 'provider_subject', + ?, + TRUE, + 'ACTIVE' + ) + """, + bindingId, + providerCode, + subject); + }); + } + + private long insertNamespace( + String slug, + String createdBy) { + Long id = jdbcTemplate.queryForObject( + """ + INSERT INTO namespace ( + slug, + display_name, + type, + created_by + ) VALUES (?, ?, 'TEAM', ?) + RETURNING id + """, + Long.class, + slug, + slug, + createdBy); + return id == null ? 0L : id; + } + + private long insertSkill( + long namespaceId, + String slug, + String ownerId) { + Long id = jdbcTemplate.queryForObject( + """ + INSERT INTO skill ( + namespace_id, + slug, + display_name, + owner_id, + visibility, + status, + created_by, + updated_by + ) VALUES ( + ?, + ?, + ?, + ?, + 'PUBLIC', + 'ACTIVE', + ?, + ? + ) + RETURNING id + """, + Long.class, + namespaceId, + slug, + slug, + ownerId, + ownerId, + ownerId); + return id == null ? 0L : id; + } + + private void insertSearchDocument( + long skillId, + long namespaceId, + String namespaceSlug, + String ownerId) { + jdbcTemplate.update(""" + INSERT INTO skill_search_document ( + skill_id, + namespace_id, + namespace_slug, + owner_id, + title, + visibility, + status + ) VALUES ( + ?, + ?, + ?, + ?, + 'Secondary skill', + 'PUBLIC', + 'ACTIVE' + ) + """, + skillId, + namespaceId, + namespaceSlug, + ownerId); + } + + private void insertToken( + String userId, + String name, + String prefix, + String hashCharacter) { + String tokenHashSource = + userId + ":" + name + ":" + hashCharacter; + jdbcTemplate.update(""" + INSERT INTO api_token ( + subject_type, + subject_id, + user_id, + name, + token_prefix, + token_hash, + scope_json + ) VALUES ( + 'USER', + ?, + ?, + ?, + ?, + md5(?) || md5(? || ':2'), + '[]'::jsonb + ) + """, + userId, + userId, + name, + prefix, + tokenHashSource, + tokenHashSource); + } + + private void installSessionTaskFailureTrigger() { + jdbcTemplate.execute(""" + CREATE OR REPLACE FUNCTION + account_merge_test_fail_session_task() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION + 'injected account merge session task failure'; + END; + $$ + """); + jdbcTemplate.execute(""" + CREATE TRIGGER + account_merge_test_fail_session_task + BEFORE INSERT + ON account_merge_session_revocation + FOR EACH ROW + EXECUTE FUNCTION + account_merge_test_fail_session_task() + """); + } + + private void removeSessionTaskFailureTrigger() { + jdbcTemplate.execute(""" + DROP TRIGGER IF EXISTS + account_merge_test_fail_session_task + ON account_merge_session_revocation + """); + jdbcTemplate.execute(""" + DROP FUNCTION IF EXISTS + account_merge_test_fail_session_task() + """); + } + + private static Stream + atomicFailureStages() { + return Stream.of(FailureStage.values()); + } + + private void installFailureTrigger( + FailureStage stage) { + jdbcTemplate.execute(""" + CREATE OR REPLACE FUNCTION + account_merge_test_fail_stage() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION + 'injected account merge stage failure'; + END; + $$ + """); + jdbcTemplate.execute( + "CREATE TRIGGER " + + "account_merge_test_fail_stage " + + "BEFORE " + + stage.event + + " ON " + + stage.table + + " FOR EACH ROW " + + stage.whenClause + + " EXECUTE FUNCTION " + + "account_merge_test_fail_stage()"); + } + + private void removeFailureTrigger( + FailureStage stage) { + jdbcTemplate.execute( + "DROP TRIGGER IF EXISTS " + + "account_merge_test_fail_stage ON " + + stage.table); + jdbcTemplate.execute(""" + DROP FUNCTION IF EXISTS + account_merge_test_fail_stage() + """); + } + + private long count(String sql, Object... arguments) { + return singleLong(sql, arguments); + } + + private long singleLong( + String sql, + Object... arguments) { + Long value = jdbcTemplate.queryForObject( + sql, + Long.class, + arguments); + return value == null ? 0L : value; + } + + private String singleString( + String sql, + Object... arguments) { + return jdbcTemplate.queryForObject( + sql, + String.class, + arguments); + } + + private static String withCurrentSchema(String url) { + return url + (url.contains("?") ? "&" : "?") + + "currentSchema=" + + SCHEMA; + } + + private static void createSchema( + String url, + String username, + String password) { + try (Connection connection = + DriverManager.getConnection( + url, + username, + password); + Statement statement = + connection.createStatement()) { + statement.execute( + "DROP SCHEMA IF EXISTS " + + SCHEMA + + " CASCADE"); + statement.execute( + "CREATE SCHEMA " + SCHEMA); + } catch (Exception exception) { + throw new IllegalStateException( + "Failed to create account merge test schema", + exception); + } + } + + 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 record PreparedMerge( + AccountMergeActor actor, + UUID intentId, + int previewVersion) { + } + + private record SeededResources( + long sharedNamespaceId, + long secondaryNamespaceId, + long sharedSkillId, + long secondarySkillId) { + } + + private enum FailureStage { + BINDING( + "binding", + "identity_binding", + "UPDATE", + ""), + CREDENTIAL( + "credential", + "local_credential", + "DELETE", + ""), + MEMBERSHIP( + "membership", + "namespace_member", + "DELETE", + ""), + BUSINESS_OWNERSHIP( + "ownership", + "skill", + "UPDATE", + ""), + TOKEN_REVOCATION( + "token", + "api_token", + "UPDATE", + ""), + ACCOUNT_STATUS( + "account", + "user_account", + "UPDATE", + "WHEN (NEW.status = 'MERGED')"), + INTENT_STATUS( + "intent", + "account_merge_intent", + "UPDATE", + "WHEN (NEW.status = 'COMPLETED')"), + COMPLETION_AUDIT( + "audit", + "audit_log", + "INSERT", + "WHEN (NEW.action = " + + "'ACCOUNT_MERGE_COMPLETED')"); + + private final String suffix; + private final String table; + private final String event; + private final String whenClause; + + FailureStage( + String suffix, + String table, + String event, + String whenClause) { + this.suffix = suffix; + this.table = table; + this.event = event; + this.whenClause = whenClause; + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AccountMergeSessionRevocationReadinessTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AccountMergeSessionRevocationReadinessTest.java new file mode 100644 index 00000000..d9eedc31 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AccountMergeSessionRevocationReadinessTest.java @@ -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; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisClusterIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisClusterIntegrationTest.java index 222cf39a..6975a1bc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisClusterIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RedisClusterIntegrationTest.java @@ -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 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.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(); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerEnabledTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerEnabledTest.java new file mode 100644 index 00000000..e6553d45 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerEnabledTest.java @@ -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()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java index c97a803f..e0e4ece5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java @@ -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; + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java index f3d2d117..2a766ddd 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java @@ -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")); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/AccountMergeSessionRevocationRepositoryPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/AccountMergeSessionRevocationRepositoryPostgresTest.java new file mode 100644 index 00000000..86564cd1 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/AccountMergeSessionRevocationRepositoryPostgresTest.java @@ -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"); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AccountMergeAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AccountMergeAppServiceTest.java new file mode 100644 index 00000000..d1db210f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AccountMergeAppServiceTest.java @@ -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")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginAppServiceTest.java index d7ff41a0..ac0effbd 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/CasLoginAppServiceTest.java @@ -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, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityLinkAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityLinkAppServiceTest.java index 42f831f0..267d4adf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityLinkAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityLinkAppServiceTest.java @@ -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( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeIntentCleanupTaskTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeIntentCleanupTaskTest.java new file mode 100644 index 00000000..1ff7a389 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeIntentCleanupTaskTest.java @@ -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); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeSessionRedisIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeSessionRedisIntegrationTest.java new file mode 100644 index 00000000..48394139 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeSessionRedisIntegrationTest.java @@ -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 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 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 + indexedSessionRepository( + FindByIndexNameSessionRepository repository) { + return (FindByIndexNameSessionRepository) repository; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static SessionRepository sessionRepository( + SessionRepository repository) { + return (SessionRepository) repository; + } + + private static final class Fixture implements AutoCloseable { + + private final LettuceConnectionFactory connectionFactory; + private final RedisTemplate template; + private final String namespace = + "skillhub:test:account-merge:" + UUID.randomUUID(); + private final RedisIndexedSessionRepository redisSessions; + private final FindByIndexNameSessionRepository 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 keys = template.keys(namespace + "*"); + if (keys != null && !keys.isEmpty()) { + template.delete(keys); + } + redisSessions.destroy(); + connectionFactory.destroy(); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeSessionRevocationTaskTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeSessionRevocationTaskTest.java new file mode 100644 index 00000000..08d8b1a6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/AccountMergeSessionRevocationTaskTest.java @@ -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 + 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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/AccountMergeRouteRequestMatcher.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/AccountMergeRouteRequestMatcher.java new file mode 100644 index 00000000..b88ac972 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/AccountMergeRouteRequestMatcher.java @@ -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/")); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityProofService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityProofService.java new file mode 100644 index 00000000..8cd7a4f4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityProofService.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityProof.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityProof.java new file mode 100644 index 00000000..90d9b8d3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityProof.java @@ -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. + * + *

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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityProofService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityProofService.java new file mode 100644 index 00000000..b0520690 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityProofService.java @@ -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); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java index 6192f21c..e0961cd5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java @@ -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) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeActor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeActor.java new file mode 100644 index 00000000..a9b56d0f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeActor.java @@ -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. + * + *

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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserFlow.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserFlow.java new file mode 100644 index 00000000..d8c586c0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserFlow.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserFlowReference.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserFlowReference.java new file mode 100644 index 00000000..815e35e4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserFlowReference.java @@ -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 +) { +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserPhase.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserPhase.java new file mode 100644 index 00000000..7b47340d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeBrowserPhase.java @@ -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 +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeCompletion.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeCompletion.java new file mode 100644 index 00000000..732b575d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeCompletion.java @@ -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"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeDataGateway.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeDataGateway.java new file mode 100644 index 00000000..2410269e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeDataGateway.java @@ -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. + * + *

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); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeException.java new file mode 100644 index 00000000..cb45a0f9 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeException.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeFailureCode.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeFailureCode.java new file mode 100644 index 00000000..c63e25a5 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeFailureCode.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntent.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntent.java new file mode 100644 index 00000000..427ed0dd --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntent.java @@ -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 +) { +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentEntity.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentEntity.java new file mode 100644 index 00000000..6b82a374 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentEntity.java @@ -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. + * + *

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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentRepository.java new file mode 100644 index 00000000..e6cae1f9 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentRepository.java @@ -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 { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select intent + from AccountMergeIntentEntity intent + where intent.id = :intentId + """) + Optional 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 + findActiveByParticipantForUpdate( + @Param("userId") String userId, + @Param("statuses") + Collection + 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 findExpiredForUpdate( + @Param("now") Instant now, + @Param("statuses") + Collection + statuses, + Pageable pageable); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentService.java new file mode 100644 index 00000000..d92f6fed --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentService.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentStatus.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentStatus.java new file mode 100644 index 00000000..611cf224 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentStatus.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentTransaction.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentTransaction.java new file mode 100644 index 00000000..521139ad --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentTransaction.java @@ -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 + 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 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 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) { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeMetrics.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeMetrics.java new file mode 100644 index 00000000..24053d32 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeMetrics.java @@ -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. + * + *

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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePlan.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePlan.java new file mode 100644 index 00000000..0b881bdb --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePlan.java @@ -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. + * + *

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 identityProviders, + LocalCredentialAction localCredentialAction, + List blockedPlatformRoles, + List namespaceChanges, + List apiTokensToRevoke, + int skillOwnershipCount, + SocialSummary social, + NotificationSummary notifications, + List 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 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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePreview.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePreview.java new file mode 100644 index 00000000..55539739 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePreview.java @@ -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"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePrimaryProof.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePrimaryProof.java new file mode 100644 index 00000000..538ad04e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergePrimaryProof.java @@ -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"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProperties.java new file mode 100644 index 00000000..ae3beb87 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProperties.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderPrimaryProof.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderPrimaryProof.java new file mode 100644 index 00000000..3d84facd --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderPrimaryProof.java @@ -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"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderProofService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderProofService.java new file mode 100644 index 00000000..2173caa8 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderProofService.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeSessionManager.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeSessionManager.java new file mode 100644 index 00000000..3dca04bf --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeSessionManager.java @@ -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. + * + *

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 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 + 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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeStateHasher.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeStateHasher.java new file mode 100644 index 00000000..d431e54e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeStateHasher.java @@ -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); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java index 413622d4..51d2a395 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java @@ -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; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java index 225300be..e00182b4 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java @@ -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 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 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 accountMergeFlow = + consumeAccountMergeFlow(provider, context); + if (accountMergeFlow.isPresent()) { + return authenticateAccountMergeFlow( + accountMergeFlow.orElseThrow(), + provider, + result, + context); + } Optional 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 + consumeAccountMergeFlow( + ResolvedProviderHandle provider, + IdentityLoginContext context) { + if (!(RequestContextHolder.getRequestAttributes() + instanceof ServletRequestAttributes attributes)) { + return Optional.empty(); + } + return accountMergeSessionManager.consumeBrowserFlow( + attributes.getRequest(), + provider.providerCode(), + context); + } + private Optional 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 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 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 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 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 accountMergeIntentId( + String returnTo) { + if (returnTo == null + || !returnTo.startsWith( + "/settings/accounts?")) { + return Optional.empty(); + } + return intentIdParameter(returnTo); + } + + private Optional 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) { } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java index 3899c6eb..92e3453b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java @@ -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()); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java index 67f13102..8fea235d 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java @@ -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 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; + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentTransactionTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentTransactionTest.java new file mode 100644 index 00000000..c543ebd5 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeIntentTransactionTest.java @@ -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 + activeStatuses() { + return java.util.Set.of( + AccountMergeIntentStatus + .PENDING_SECONDARY_PROOF, + AccountMergeIntentStatus + .READY_FOR_PREVIEW, + AccountMergeIntentStatus + .READY_TO_CONFIRM, + AccountMergeIntentStatus + .FAILED_CONFLICT); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderProofServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderProofServiceTest.java new file mode 100644 index 00000000..b11ab91c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeProviderProofServiceTest.java @@ -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")); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeSessionManagerTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeSessionManagerTest.java new file mode 100644 index 00000000..ae316cc6 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeSessionManagerTest.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java index 160d9550..280b2b88 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java @@ -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 diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java index 639bea0f..acd5cd4f 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -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"); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java index 948b58e6..9d2baf66 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java @@ -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() { diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipalTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipalTest.java new file mode 100644 index 00000000..4a380521 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipalTest.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.auth.rbac; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.security.Principal; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class PlatformPrincipalTest { + + @Test + void exposesStablePlatformUserIdAsPrincipalName() { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_stable", + "Display Name", + "user@example.com", + null, + "github", + Set.of("USER")); + + assertThat(principal).isInstanceOf(Principal.class); + assertThat(principal.getName()).isEqualTo("usr_stable"); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index fa9fb2cf..4285f372 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -108,6 +108,43 @@ class ApiTokenAuthenticationFilterTest { verify(apiTokenService, never()).touchLastUsed(token); } + @Test + void shouldRejectMergedSecondaryUserTokens() throws Exception { + ApiToken token = new ApiToken( + "user-merged", + "cli", + "sk_test", + "hash", + "[\"skill:publish\"]"); + UserAccount user = new UserAccount( + "user-merged", + "Merged", + "merged@example.com", + ""); + user.setStatus(UserStatus.MERGED); + user.setMergedToUserId("user-primary"); + + when(apiTokenService.validateToken("raw-token")) + .thenReturn(Optional.of(token)); + when(userAccountRepository.findById("user-merged")) + .thenReturn(Optional.of(user)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/publish"); + request.addHeader("Authorization", "Bearer raw-token"); + + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertEquals(MockHttpServletResponse.SC_UNAUTHORIZED, response.getStatus()); + assertNull(chain.getRequest()); + verify(roleBindingRepository, never()).findByUserId("user-merged"); + verify(apiTokenService, never()).touchLastUsed(token); + } + @Test void shouldRejectUnknownBearerTokenOnCliReadRoutes() throws Exception { when(apiTokenService.validateToken("unknown-token")).thenReturn(Optional.empty()); diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/sse/SseEmitterManager.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/sse/SseEmitterManager.java index 341a4d50..76202322 100644 --- a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/sse/SseEmitterManager.java +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/sse/SseEmitterManager.java @@ -81,6 +81,31 @@ public class SseEmitterManager { } } + /** + * Closes every live stream owned by one account. + * + *

Account merge uses this after the PostgreSQL commit so a secondary + * account cannot retain a long-lived notification channel while Redis + * session deletion is being retried. + */ + public void closeAll(String userId) { + CopyOnWriteArrayList userEmitters = + emitters.remove(userId); + if (userEmitters == null) { + return; + } + for (TrackedEmitter trackedEmitter : userEmitters) { + cleanup(userId, userEmitters, trackedEmitter); + try { + trackedEmitter.emitter().complete(); + } catch (IllegalStateException exception) { + log.debug( + "Emitter already completed while closing user {}", + userId); + } + } + } + @Scheduled(fixedRate = HEARTBEAT_INTERVAL) public void heartbeat() { emitters.forEach((userId, userEmitters) -> { diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java index 43646817..8e25a058 100644 --- a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java @@ -174,6 +174,41 @@ class SseEmitterManagerTest { assertEquals(1, manager.emittersForUser("user-2")); } + @Test + void closeAll_shouldCloseOnlyTheRequestedUsersEmitters() { + TestEmitter first = new TestEmitter(); + TestEmitter second = new TestEmitter(); + TestEmitter other = new TestEmitter(); + emitters.add(first); + emitters.add(second); + emitters.add(other); + manager.register("user-merged"); + manager.register("user-merged"); + manager.register("user-active"); + + manager.closeAll("user-merged"); + + assertTrue(!first.isOpen()); + assertTrue(!second.isOpen()); + assertTrue(other.isOpen()); + assertEquals(1, manager.totalEmitters()); + assertEquals(0, manager.emittersForUser("user-merged")); + assertEquals(1, manager.emittersForUser("user-active")); + } + + @Test + void closeAll_shouldBeIdempotentWhenCompletionThrows() { + TestEmitter emitter = new TestEmitter(); + emitter.throwOnComplete(); + emitters.add(emitter); + manager.register("user-merged"); + + assertDoesNotThrow(() -> manager.closeAll("user-merged")); + assertDoesNotThrow(() -> manager.closeAll("user-merged")); + assertEquals(0, manager.totalEmitters()); + assertEquals(0, manager.emittersForUser("user-merged")); + } + private static final class TestEmitter extends SseEmitter { private final AtomicInteger errorCallbacks = new AtomicInteger(0); private Runnable completionCallback = () -> {}; diff --git a/web/e2e/settings-routing.spec.ts b/web/e2e/settings-routing.spec.ts index e5ed15a7..a82412a9 100644 --- a/web/e2e/settings-routing.spec.ts +++ b/web/e2e/settings-routing.spec.ts @@ -8,9 +8,11 @@ test.describe('Settings Routing (Real API)', () => { await registerSession(page, testInfo) }) - test('redirects accounts route to security settings', async ({ page }) => { + test('keeps the accounts route and renders its capability state', async ({ page }) => { await page.goto('/settings/accounts') - await expect(page).toHaveURL('/settings/security') - await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() + await expect(page).toHaveURL('/settings/accounts') + await expect(page.getByRole('heading', { + name: /Account merging|Merge accounts/, + })).toBeVisible() }) }) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 5b7fd3eb..3b2b8ef7 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -44,6 +44,7 @@ vi.mock('@/shared/lib/api-error', () => ({ import { WEB_API_PREFIX, + accountApi, buildApiUrl, fetchText, getDirectAuthRuntimeConfig, @@ -297,6 +298,99 @@ describe('identityLinkApi', () => { }) }) +describe('accountApi', () => { + it('normalizes account merge capabilities from generated types', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ + code: 0, + msg: 'OK', + data: { + enabled: true, + primaryMethods: [{ + providerCode: 'local', + displayName: 'Local password', + methodType: 'LOCAL_PASSWORD', + }], + secondaryMethods: [{ + providerCode: 'github', + displayName: 'GitHub', + methodType: 'OAUTH_REDIRECT', + }], + }, + timestamp: '2026-07-31T00:00:00Z', + requestId: 'req-account-merge-capabilities', + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const capabilities = await accountApi.capabilities() + + expect(capabilities).toEqual({ + enabled: true, + primaryMethods: [{ + providerCode: 'local', + displayName: 'Local password', + methodType: 'LOCAL_PASSWORD', + }], + secondaryMethods: [{ + providerCode: 'github', + displayName: 'GitHub', + methodType: 'OAUTH_REDIRECT', + }], + }) + const request = fetchMock.mock.calls[0]?.[0] as Request + expect(request.url).toBe( + 'http://localhost/api/v1/account/merge/capabilities', + ) + expect(request.method).toBe('GET') + }) + + it('sends preview version with CSRF and preserves stable failures', async () => { + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + cookie: 'XSRF-TOKEN=account-merge-csrf', + }, + }) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ + code: 409, + msg: 'Preview changed.', + reasonCode: 'MERGE_PREVIEW_STALE', + timestamp: '2026-07-31T00:00:00Z', + requestId: 'req-account-merge-stale', + }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(accountApi.confirm( + '8f2bb16c-6e11-4e48-a7a4-6be46ecb0902', + 3, + )).rejects.toMatchObject({ + status: 409, + reasonCode: 'MERGE_PREVIEW_STALE', + }) + + const request = fetchMock.mock.calls[0]?.[0] as Request + expect(request.url).toBe( + 'http://localhost/api/v1/account/merge/intents/' + + '8f2bb16c-6e11-4e48-a7a4-6be46ecb0902/confirm', + ) + expect(request.headers.get('X-XSRF-TOKEN')) + .toBe('account-merge-csrf') + await expect(request.clone().json()).resolves.toEqual({ + previewVersion: 3, + }) + }) +}) + describe('getDirectAuthRuntimeConfig', () => { it('returns disabled when no runtime config is present', () => { const config = getDirectAuthRuntimeConfig() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3e843ab8..07844f01 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -7,12 +7,15 @@ import type { ApiToken, CreateTokenRequest, CreateTokenResponse, - MergeConfirmRequest, LocalLoginRequest, LocalRegisterRequest, - MergeInitiateRequest, - MergeInitiateResponse, - MergeVerifyRequest, + AccountMergeAuthenticationMethod, + AccountMergeCapabilities, + AccountMergeCompletion, + AccountMergeCredentialRequest, + AccountMergeIntent, + AccountMergeMethodType, + AccountMergePreview, ReviewSkillDetail, ReviewTask, PromotionSortBy, @@ -448,8 +451,6 @@ type IdentityLinkBindingSchema = components['schemas']['IdentityLinkBindingRespo type IdentityLinkProviderSchema = components['schemas']['IdentityLinkProviderResponse'] type IdentityLinkBrowserStartSchema = components['schemas']['IdentityLinkBrowserStartResponse'] -type IdentityLinkErrorSchema = - components['schemas']['IdentityLinkErrorResponse'] function normalizeIdentityLinkMethodTypes( methodTypes: IdentityLinkBindingSchema['methodTypes'] @@ -553,9 +554,14 @@ type OpenApiEnvelopeResult = { response: Response } +type ApiFailureEnvelope = { + msg?: string + reasonCode?: string +} + function isApiFailureEnvelope( value: unknown, -): value is IdentityLinkErrorSchema { +): value is ApiFailureEnvelope { return typeof value === 'object' && value !== null && ('msg' in value || 'reasonCode' in value) @@ -787,35 +793,425 @@ export const identityLinkApi = { }, } +type AccountMergeCapabilitiesSchema = + components['schemas']['AccountMergeCapabilitiesResponse'] +type AccountMergeAuthenticationMethodSchema = + components['schemas']['AccountMergeAuthenticationMethodResponse'] +type AccountMergePrimaryProofSchema = + components['schemas']['AccountMergePrimaryProofResponse'] +type AccountMergeBrowserStartSchema = + components['schemas']['AccountMergeBrowserStartResponse'] +type AccountMergeIntentSchema = + components['schemas']['AccountMergeIntentResponse'] +type AccountMergePreviewSchema = + components['schemas']['AccountMergePreviewResponse'] +type AccountMergeCompletionSchema = + components['schemas']['AccountMergeCompletionResponse'] +type AccountMergeNamespaceChangeSchema = + components['schemas']['NamespaceChange'] +type AccountMergeTokenSchema = + components['schemas']['ApiToken'] +type AccountMergeDiscardedRatingSchema = + components['schemas']['DiscardedRating'] +type AccountMergeConflictSchema = + components['schemas']['Conflict'] + +const accountMergeMethodTypes = new Set([ + 'LOCAL_PASSWORD', + 'OAUTH_REDIRECT', + 'CAS_REDIRECT', + 'DIRECT_PASSWORD', +]) + +function invalidAccountMergeResponse(): never { + throw new ApiError('apiError.invalidResponse', 500) +} + +function normalizeAccountMergeMethod( + method: AccountMergeAuthenticationMethodSchema, +): AccountMergeAuthenticationMethod { + if ( + !method.providerCode + || !method.displayName + || !method.methodType + || !accountMergeMethodTypes.has( + method.methodType as AccountMergeMethodType, + ) + ) { + return invalidAccountMergeResponse() + } + return { + ...method, + providerCode: method.providerCode, + displayName: method.displayName, + methodType: method.methodType as AccountMergeMethodType, + } +} + +function normalizeAccountMergeCapabilities( + capabilities: AccountMergeCapabilitiesSchema, +): AccountMergeCapabilities { + if (capabilities.enabled === undefined) { + return invalidAccountMergeResponse() + } + return { + ...capabilities, + enabled: capabilities.enabled, + primaryMethods: (capabilities.primaryMethods ?? []) + .map(normalizeAccountMergeMethod), + secondaryMethods: (capabilities.secondaryMethods ?? []) + .map(normalizeAccountMergeMethod), + } +} + +function normalizeAccountMergeIntent( + intent: AccountMergeIntentSchema, +): AccountMergeIntent { + if ( + !intent.id + || !intent.status + || !intent.expiresAt + ) { + return invalidAccountMergeResponse() + } + return { + ...intent, + id: intent.id, + status: intent.status, + expiresAt: intent.expiresAt, + secondaryMethods: (intent.secondaryMethods ?? []) + .map(normalizeAccountMergeMethod), + } +} + +function requireAccountMergeActionUrl( + response: AccountMergeBrowserStartSchema, +): string { + if (!response.actionUrl || !response.actionUrl.startsWith('/')) { + return invalidAccountMergeResponse() + } + return response.actionUrl +} + +function normalizeAccountMergeNamespaceChange( + change: AccountMergeNamespaceChangeSchema, +) { + if ( + change.namespaceId === undefined + || !change.namespaceSlug + || change.blocked === undefined + ) { + return invalidAccountMergeResponse() + } + return { + namespaceId: change.namespaceId, + namespaceSlug: change.namespaceSlug, + primaryRole: change.primaryRole, + secondaryRole: change.secondaryRole, + resultingRole: change.resultingRole, + blocked: change.blocked, + } +} + +function normalizeAccountMergeToken( + token: AccountMergeTokenSchema, +) { + if (!token.name || !token.prefix) { + return invalidAccountMergeResponse() + } + return { + name: token.name, + prefix: token.prefix, + } +} + +function normalizeAccountMergeConflict( + conflict: AccountMergeConflictSchema, +) { + if ( + !conflict.code + || !conflict.resource + || !conflict.suggestedAction + ) { + return invalidAccountMergeResponse() + } + return { + code: conflict.code, + resource: conflict.resource, + suggestedAction: conflict.suggestedAction, + } +} + +function normalizeDiscardedRating( + rating: AccountMergeDiscardedRatingSchema, +) { + if ( + rating.skillId === undefined + || rating.score === undefined + ) { + return invalidAccountMergeResponse() + } + return { + skillId: rating.skillId, + score: rating.score, + } +} + +function count(value: number | undefined): number { + if (value === undefined) { + return invalidAccountMergeResponse() + } + return value +} + +function normalizeAccountMergePreview( + preview: AccountMergePreviewSchema, +): AccountMergePreview { + if ( + !preview.intentId + || !preview.status + || preview.previewVersion === undefined + || !preview.expiresAt + || preview.confirmable === undefined + || !preview.localCredentialAction + || !preview.social + || !preview.notifications + ) { + return invalidAccountMergeResponse() + } + return { + ...preview, + intentId: preview.intentId, + status: preview.status, + previewVersion: preview.previewVersion, + expiresAt: preview.expiresAt, + confirmable: preview.confirmable, + identityProviders: [...(preview.identityProviders ?? [])], + localCredentialAction: preview.localCredentialAction, + blockedPlatformRoles: [...(preview.blockedPlatformRoles ?? [])], + namespaceChanges: (preview.namespaceChanges ?? []) + .map(normalizeAccountMergeNamespaceChange), + apiTokensToRevoke: (preview.apiTokensToRevoke ?? []) + .map(normalizeAccountMergeToken), + skillOwnershipCount: count(preview.skillOwnershipCount), + social: { + starsMoved: count(preview.social.starsMoved), + duplicateStarsDiscarded: count( + preview.social.duplicateStarsDiscarded, + ), + ratingsMoved: count(preview.social.ratingsMoved), + duplicateRatingsDiscarded: count( + preview.social.duplicateRatingsDiscarded, + ), + subscriptionsMoved: count(preview.social.subscriptionsMoved), + duplicateSubscriptionsDiscarded: count( + preview.social.duplicateSubscriptionsDiscarded, + ), + discardedRatings: (preview.social.discardedRatings ?? []) + .map(normalizeDiscardedRating), + }, + notifications: { + notificationsMoved: count( + preview.notifications.notificationsMoved, + ), + preferencesMoved: count( + preview.notifications.preferencesMoved, + ), + duplicatePreferencesDiscarded: count( + preview.notifications.duplicatePreferencesDiscarded, + ), + governanceNotificationsMoved: count( + preview.notifications.governanceNotificationsMoved, + ), + }, + conflicts: (preview.conflicts ?? []) + .map(normalizeAccountMergeConflict), + } +} + +function normalizeAccountMergeCompletion( + completion: AccountMergeCompletionSchema, +): AccountMergeCompletion { + if ( + !completion.intentId + || !completion.status + || !completion.completedAt + ) { + return invalidAccountMergeResponse() + } + return { + ...completion, + intentId: completion.intentId, + status: completion.status, + completedAt: completion.completedAt, + } +} + export const accountApi = { - async initiateMerge(request: MergeInitiateRequest): Promise { - return fetchJson('/api/v1/account/merge/initiate', { - method: 'POST', - headers: await ensureCsrfHeaders({ - 'Content-Type': 'application/json', - }), - body: JSON.stringify(request), - }) + async capabilities(): Promise { + const result = await client.GET( + '/api/v1/account/merge/capabilities', + { headers: withRequestHeaders() }, + ) + return normalizeAccountMergeCapabilities( + unwrapOpenApiEnvelope(result), + ) }, - async verifyMerge(request: MergeVerifyRequest): Promise { - await fetchJson('/api/v1/account/merge/verify', { - method: 'POST', - headers: await ensureCsrfHeaders({ - 'Content-Type': 'application/json', - }), - body: JSON.stringify(request), - }) + async reauthenticatePrimaryLocal(password: string): Promise { + const result = await client.POST( + '/api/v1/account/merge/reauthenticate/local', + { + headers: await ensureCsrfHeaders(), + body: { password }, + }, + ) + unwrapOpenApiEnvelope(result) }, - async confirmMerge(request: MergeConfirmRequest): Promise { - await fetchJson('/api/v1/account/merge/confirm', { - method: 'POST', - headers: await ensureCsrfHeaders({ - 'Content-Type': 'application/json', - }), - body: JSON.stringify(request), - }) + async reauthenticatePrimaryCredential( + providerCode: string, + credentials: AccountMergeCredentialRequest, + ): Promise { + const result = await client.POST( + '/api/v1/account/merge/reauthenticate/credential', + { + headers: await ensureCsrfHeaders(), + body: { providerCode, ...credentials }, + }, + ) + unwrapOpenApiEnvelope(result) + }, + + async preparePrimaryBrowser(providerCode: string): Promise { + const result = await client.POST( + '/api/v1/account/merge/reauthenticate/browser', + { + headers: await ensureCsrfHeaders(), + body: { providerCode }, + }, + ) + return requireAccountMergeActionUrl( + unwrapOpenApiEnvelope(result), + ) + }, + + async createIntent(): Promise { + const result = await client.POST( + '/api/v1/account/merge/intents', + { headers: await ensureCsrfHeaders() }, + ) + return normalizeAccountMergeIntent( + unwrapOpenApiEnvelope(result), + ) + }, + + async getIntent(intentId: string): Promise { + const result = await client.GET( + '/api/v1/account/merge/intents/{intentId}', + { + params: { path: { intentId } }, + headers: withRequestHeaders(), + }, + ) + return normalizeAccountMergeIntent( + unwrapOpenApiEnvelope(result), + ) + }, + + async authenticateSecondaryLocal( + intentId: string, + credentials: AccountMergeCredentialRequest, + ): Promise { + const result = await client.POST( + '/api/v1/account/merge/intents/{intentId}/secondary-auth/local', + { + params: { path: { intentId } }, + headers: await ensureCsrfHeaders(), + body: credentials, + }, + ) + return normalizeAccountMergeIntent( + unwrapOpenApiEnvelope(result), + ) + }, + + async authenticateSecondaryCredential( + intentId: string, + providerCode: string, + credentials: AccountMergeCredentialRequest, + ): Promise { + const result = await client.POST( + '/api/v1/account/merge/intents/{intentId}/secondary-auth/credential', + { + params: { path: { intentId } }, + headers: await ensureCsrfHeaders(), + body: { providerCode, ...credentials }, + }, + ) + return normalizeAccountMergeIntent( + unwrapOpenApiEnvelope(result), + ) + }, + + async prepareSecondaryBrowser( + intentId: string, + providerCode: string, + ): Promise { + const result = await client.POST( + '/api/v1/account/merge/intents/{intentId}/secondary-auth/browser', + { + params: { path: { intentId } }, + headers: await ensureCsrfHeaders(), + body: { providerCode }, + }, + ) + return requireAccountMergeActionUrl( + unwrapOpenApiEnvelope(result), + ) + }, + + async preview(intentId: string): Promise { + const result = await client.POST( + '/api/v1/account/merge/intents/{intentId}/preview', + { + params: { path: { intentId } }, + headers: await ensureCsrfHeaders(), + }, + ) + return normalizeAccountMergePreview( + unwrapOpenApiEnvelope(result), + ) + }, + + async confirm( + intentId: string, + previewVersion: number, + ): Promise { + const result = await client.POST( + '/api/v1/account/merge/intents/{intentId}/confirm', + { + params: { path: { intentId } }, + headers: await ensureCsrfHeaders(), + body: { previewVersion }, + }, + ) + return normalizeAccountMergeCompletion( + unwrapOpenApiEnvelope(result), + ) + }, + + async cancel(intentId: string): Promise { + const result = await client.DELETE( + '/api/v1/account/merge/intents/{intentId}', + { + params: { path: { intentId } }, + headers: await ensureCsrfHeaders(), + }, + ) + return normalizeAccountMergeIntent( + unwrapOpenApiEnvelope(result), + ) }, } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index b4316687..614c12d1 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1792,6 +1792,159 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/account/merge/reauthenticate/local": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Freshly reauthenticate the primary account with its local password */ + post: operations["reauthenticatePrimaryLocal"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/reauthenticate/credential": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Freshly authenticate the primary account through a credential provider */ + post: operations["reauthenticatePrimaryCredential"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/reauthenticate/browser": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Start primary fresh authentication through a browser provider */ + post: operations["reauthenticatePrimaryBrowser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/intents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a safe account-merge intent */ + post: operations["createIntent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/intents/{intentId}/secondary-auth/local": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Prove control of the secondary local account */ + post: operations["authenticateSecondaryLocal"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/intents/{intentId}/secondary-auth/credential": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Independently authenticate the secondary through a credential provider */ + post: operations["authenticateSecondaryCredential"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/intents/{intentId}/secondary-auth/browser": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Start independent secondary authentication through a browser provider */ + post: operations["prepareSecondaryBrowser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/intents/{intentId}/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Build a versioned account-merge preview */ + post: operations["preview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/intents/{intentId}/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Confirm an unchanged account-merge preview */ + post: operations["confirm"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/account/merge/initiate": { parameters: { query?: never; @@ -1817,7 +1970,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["confirm"]; + post: operations["confirm_1"]; delete?: never; options?: never; head?: never; @@ -3336,6 +3489,41 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/account/merge/intents/{intentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read the current safe account-merge intent */ + get: operations["getIntent_1"]; + put?: never; + post?: never; + /** Cancel an active account-merge intent */ + delete: operations["cancel_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/merge/capabilities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List safe account-merge authentication methods */ + get: operations["capabilities"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download": { parameters: { query?: never; @@ -4280,6 +4468,180 @@ export interface components { mergeRequestId: number; verificationToken: string; }; + AccountMergeLocalReauthenticationRequest: { + password: string; + }; + AccountMergeErrorResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + /** @enum {string} */ + reasonCode: "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"; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AccountMergePrimaryProofResponse: { + method?: string; + /** Format: date-time */ + expiresAt?: string; + }; + ApiResponseAccountMergePrimaryProofResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AccountMergePrimaryProofResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AccountMergeCredentialAuthenticationRequest: { + providerCode: string; + username: string; + password: string; + }; + AccountMergeBrowserAuthenticationRequest: { + providerCode: string; + }; + AccountMergeBrowserStartResponse: { + actionUrl?: string; + }; + ApiResponseAccountMergeBrowserStartResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AccountMergeBrowserStartResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AccountMergeAuthenticationMethodResponse: { + providerCode?: string; + displayName?: string; + methodType?: string; + }; + AccountMergeIntentResponse: { + /** Format: uuid */ + id?: string; + /** @enum {string} */ + status?: "PENDING_SECONDARY_PROOF" | "READY_FOR_PREVIEW" | "READY_TO_CONFIRM" | "COMPLETED" | "CANCELLED" | "EXPIRED" | "FAILED_CONFLICT"; + /** Format: date-time */ + expiresAt?: string; + secondaryMethods?: components["schemas"]["AccountMergeAuthenticationMethodResponse"][]; + }; + ApiResponseAccountMergeIntentResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AccountMergeIntentResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + AccountMergeSecondaryLocalAuthenticationRequest: { + username: string; + password: string; + }; + AccountMergePreviewResponse: { + /** Format: uuid */ + intentId?: string; + /** @enum {string} */ + status?: "PENDING_SECONDARY_PROOF" | "READY_FOR_PREVIEW" | "READY_TO_CONFIRM" | "COMPLETED" | "CANCELLED" | "EXPIRED" | "FAILED_CONFLICT"; + /** Format: int32 */ + previewVersion?: number; + /** Format: date-time */ + expiresAt?: string; + confirmable?: boolean; + identityProviders?: string[]; + localCredentialAction?: string; + blockedPlatformRoles?: string[]; + namespaceChanges?: components["schemas"]["NamespaceChange"][]; + apiTokensToRevoke?: components["schemas"]["ApiToken"][]; + /** Format: int32 */ + skillOwnershipCount?: number; + social?: components["schemas"]["SocialSummary"]; + notifications?: components["schemas"]["NotificationSummary"]; + conflicts?: components["schemas"]["Conflict"][]; + }; + ApiResponseAccountMergePreviewResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AccountMergePreviewResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiToken: { + name?: string; + prefix?: string; + }; + Conflict: { + code?: string; + resource?: string; + suggestedAction?: string; + }; + DiscardedRating: { + /** Format: int64 */ + skillId?: number; + /** Format: int32 */ + score?: number; + }; + NamespaceChange: { + /** Format: int64 */ + namespaceId?: number; + namespaceSlug?: string; + primaryRole?: string; + secondaryRole?: string; + resultingRole?: string; + blocked?: boolean; + }; + NotificationSummary: { + /** Format: int32 */ + notificationsMoved?: number; + /** Format: int32 */ + preferencesMoved?: number; + /** Format: int32 */ + duplicatePreferencesDiscarded?: number; + /** Format: int32 */ + governanceNotificationsMoved?: number; + }; + SocialSummary: { + /** Format: int32 */ + starsMoved?: number; + /** Format: int32 */ + duplicateStarsDiscarded?: number; + /** Format: int32 */ + ratingsMoved?: number; + /** Format: int32 */ + duplicateRatingsDiscarded?: number; + /** Format: int32 */ + subscriptionsMoved?: number; + /** Format: int32 */ + duplicateSubscriptionsDiscarded?: number; + discardedRatings?: components["schemas"]["DiscardedRating"][]; + }; + AccountMergeConfirmRequest: { + /** Format: int32 */ + previewVersion?: number; + }; + AccountMergeCompletionResponse: { + /** Format: uuid */ + intentId?: string; + /** @enum {string} */ + status?: "PENDING_SECONDARY_PROOF" | "READY_FOR_PREVIEW" | "READY_TO_CONFIRM" | "COMPLETED" | "CANCELLED" | "EXPIRED" | "FAILED_CONFLICT"; + /** Format: date-time */ + completedAt?: string; + }; + ApiResponseAccountMergeCompletionResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AccountMergeCompletionResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; MergeInitiateRequest: { secondaryIdentifier: string; }; @@ -5319,6 +5681,20 @@ export interface components { /** Format: int32 */ size?: number; }; + AccountMergeCapabilitiesResponse: { + enabled?: boolean; + primaryMethods?: components["schemas"]["AccountMergeAuthenticationMethodResponse"][]; + secondaryMethods?: components["schemas"]["AccountMergeAuthenticationMethodResponse"][]; + }; + ApiResponseAccountMergeCapabilitiesResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AccountMergeCapabilitiesResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponseCliResolveResponse: { /** Format: int32 */ code?: number; @@ -9429,6 +9805,791 @@ export interface operations { }; }; }; + reauthenticatePrimaryLocal: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AccountMergeLocalReauthenticationRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergePrimaryProofResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; + reauthenticatePrimaryCredential: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AccountMergeCredentialAuthenticationRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergePrimaryProofResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; + reauthenticatePrimaryBrowser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AccountMergeBrowserAuthenticationRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergeBrowserStartResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; + createIntent: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergeIntentResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; + authenticateSecondaryLocal: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AccountMergeSecondaryLocalAuthenticationRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergeIntentResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; + authenticateSecondaryCredential: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AccountMergeCredentialAuthenticationRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergeIntentResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; + prepareSecondaryBrowser: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AccountMergeBrowserAuthenticationRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergeBrowserStartResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; + preview: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergePreviewResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; + confirm: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AccountMergeConfirmRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergeCompletionResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; initiate: { parameters: { query?: never; @@ -9453,7 +10614,7 @@ export interface operations { }; }; }; - confirm: { + confirm_1: { parameters: { query?: never; header?: never; @@ -12029,6 +13190,259 @@ export interface operations { }; }; }; + getIntent_1: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergeIntentResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; + cancel_1: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergeIntentResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; + capabilities: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAccountMergeCapabilitiesResponse"]; + }; + }; + /** @description Invalid account merge request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Fresh authentication failed or is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Intent belongs to another browser session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Conflict, stale preview, or consumed intent */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge proof or intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + /** @description Account merge or provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AccountMergeErrorResponse"]; + }; + }; + }; + }; downloadVersion_2: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 578892f2..033e3aa5 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -133,24 +133,141 @@ export type CreateNamespaceRequest = Omit + +export type AccountMergeAuthenticationMethod = Omit< + AccountMergeAuthenticationMethodSchema, + 'providerCode' | 'displayName' | 'methodType' +> & { + providerCode: string + displayName: string + methodType: AccountMergeMethodType } -export interface MergeInitiateResponse { - mergeRequestId: number - secondaryUserId: string - verificationToken: string +export type AccountMergeCapabilities = Omit< + AccountMergeCapabilitiesSchema, + 'enabled' | 'primaryMethods' | 'secondaryMethods' +> & { + enabled: boolean + primaryMethods: AccountMergeAuthenticationMethod[] + secondaryMethods: AccountMergeAuthenticationMethod[] +} + +export type AccountMergeIntent = Omit< + AccountMergeIntentSchema, + 'id' | 'status' | 'expiresAt' | 'secondaryMethods' +> & { + id: string + status: AccountMergeIntentStatus expiresAt: string + secondaryMethods: AccountMergeAuthenticationMethod[] } -export interface MergeVerifyRequest { - mergeRequestId: number - verificationToken: string +export interface AccountMergeCredentialRequest { + username: string + password: string } -export interface MergeConfirmRequest { - mergeRequestId: number +export interface AccountMergeNamespaceChange { + namespaceId: number + namespaceSlug: string + primaryRole?: string + secondaryRole?: string + resultingRole?: string + blocked: boolean +} + +export interface AccountMergeTokenSummary { + name: string + prefix: string +} + +export interface AccountMergeSocialSummary { + starsMoved: number + duplicateStarsDiscarded: number + ratingsMoved: number + duplicateRatingsDiscarded: number + subscriptionsMoved: number + duplicateSubscriptionsDiscarded: number + discardedRatings: AccountMergeDiscardedRating[] +} + +export interface AccountMergeDiscardedRating { + skillId: number + score: number +} + +export interface AccountMergeNotificationSummary { + notificationsMoved: number + preferencesMoved: number + duplicatePreferencesDiscarded: number + governanceNotificationsMoved: number +} + +export interface AccountMergeConflict { + code: string + resource: string + suggestedAction: string +} + +export type AccountMergePreview = Omit< + AccountMergePreviewSchema, + | 'intentId' + | 'status' + | 'previewVersion' + | 'expiresAt' + | 'confirmable' + | 'identityProviders' + | 'localCredentialAction' + | 'blockedPlatformRoles' + | 'namespaceChanges' + | 'apiTokensToRevoke' + | 'skillOwnershipCount' + | 'social' + | 'notifications' + | 'conflicts' +> & { + intentId: string + status: AccountMergeIntentStatus + previewVersion: number + expiresAt: string + confirmable: boolean + identityProviders: string[] + localCredentialAction: string + blockedPlatformRoles: string[] + namespaceChanges: AccountMergeNamespaceChange[] + apiTokensToRevoke: AccountMergeTokenSummary[] + skillOwnershipCount: number + social: AccountMergeSocialSummary + notifications: AccountMergeNotificationSummary + conflicts: AccountMergeConflict[] +} + +export type AccountMergeCompletion = Omit< + AccountMergeCompletionSchema, + 'intentId' | 'status' | 'completedAt' +> & { + intentId: string + status: AccountMergeIntentStatus + completedAt: string } // Namespace types diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index ec9749a5..eb032d4a 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, type ComponentType } from 'react' -import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router' +import { createRouter, createRoute, createRootRoute } from '@tanstack/react-router' import { Layout } from './layout' import { getCurrentUser } from '@/api/client' import { RoleGuard } from '@/shared/components/role-guard' @@ -122,6 +122,10 @@ const SecuritySettingsPage = createLazyRouteComponent( () => import('@/pages/settings/security'), 'SecuritySettingsPage', ) +const AccountSettingsPage = createLazyRouteComponent( + () => import('@/pages/settings/accounts'), + 'AccountSettingsPage', +) const ProfileSettingsPage = createLazyRouteComponent( () => import('@/pages/settings/profile'), 'ProfileSettingsPage', @@ -416,10 +420,8 @@ const settingsNotificationsRoute = createRoute({ const settingsAccountsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'settings/accounts', - beforeLoad: async (ctx) => { - await requireAuth(ctx) - throw redirect({ to: '/settings/security' }) - }, + beforeLoad: requireAuth, + component: AccountSettingsPage, }) const adminUsersRoute = createRoute({ diff --git a/web/src/features/auth/account-merge-wizard.test.tsx b/web/src/features/auth/account-merge-wizard.test.tsx new file mode 100644 index 00000000..e33107fa --- /dev/null +++ b/web/src/features/auth/account-merge-wizard.test.tsx @@ -0,0 +1,187 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AccountMergeCapabilities, + AccountMergeIntent, +} from '@/api/types' + +let capabilitiesState: { + data?: AccountMergeCapabilities + isLoading: boolean + error: Error | null +} +let intentState: { + data?: AccountMergeIntent + isLoading: boolean + error: Error | null +} + +function mutation() { + return { + isPending: false, + mutateAsync: vi.fn(), + } +} + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual( + 'react-i18next', + ) + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@/api/client', () => ({ + ApiError: class ApiError extends Error { + status = 409 + reasonCode?: string + }, + buildApiUrl: (value: string) => value, +})) + +vi.mock('./use-account-merge', () => ({ + useAccountMergeCapabilities: () => capabilitiesState, + useAccountMergeIntent: () => intentState, + useAccountMergeActions: () => ({ + reauthenticatePrimaryLocal: mutation(), + reauthenticatePrimaryCredential: mutation(), + preparePrimaryBrowser: mutation(), + createIntent: mutation(), + authenticateSecondaryLocal: mutation(), + authenticateSecondaryCredential: mutation(), + prepareSecondaryBrowser: mutation(), + preview: mutation(), + confirm: mutation(), + cancel: mutation(), + }), +})) + +import { + AccountMergeWizard, + accountMergeFailureMessageKey, + parseAccountMergeCallback, +} from './account-merge-wizard' + +beforeEach(() => { + vi.stubGlobal('window', { + location: { + search: '', + assign: vi.fn(), + }, + history: { + replaceState: vi.fn(), + }, + }) + capabilitiesState = { + data: { + enabled: true, + primaryMethods: [{ + providerCode: 'local', + displayName: 'Local password', + methodType: 'LOCAL_PASSWORD', + }], + secondaryMethods: [{ + providerCode: 'github', + displayName: 'GitHub', + methodType: 'OAUTH_REDIRECT', + }], + }, + isLoading: false, + error: null, + } + intentState = { + isLoading: false, + error: null, + } +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('parseAccountMergeCallback', () => { + it('accepts only supported results, UUIDs, phases, and reason codes', () => { + expect(parseAccountMergeCallback( + '?accountMerge=secondaryProved' + + '&intentId=8f2bb16c-6e11-4e48-a7a4-6be46ecb0902', + )).toEqual({ + result: 'secondaryProved', + intentId: '8f2bb16c-6e11-4e48-a7a4-6be46ecb0902', + }) + expect(parseAccountMergeCallback( + '?accountMerge=failed' + + '&phase=SECONDARY_AUTHENTICATION' + + '&reasonCode=MERGE_PROVIDER_UNAVAILABLE', + )).toEqual({ + result: 'failed', + phase: 'SECONDARY_AUTHENTICATION', + reasonCode: 'MERGE_PROVIDER_UNAVAILABLE', + }) + expect(parseAccountMergeCallback( + '?accountMerge=failed&intentId=not-a-uuid&reasonCode=UNKNOWN', + )).toEqual({ result: 'failed' }) + expect(parseAccountMergeCallback( + '?accountMerge=unexpected', + )).toEqual({}) + }) + + it('maps stable failures without exposing raw callback values', () => { + expect(accountMergeFailureMessageKey( + 'MERGE_PREVIEW_STALE', + )).toBe('accounts.errors.previewStale') + expect(accountMergeFailureMessageKey( + 'UNTRUSTED_VALUE', + )).toBe('accounts.errors.default') + }) +}) + +describe('AccountMergeWizard', () => { + it('renders the fail-closed unavailable state from capabilities', () => { + capabilitiesState.data = { + enabled: false, + primaryMethods: [], + secondaryMethods: [], + } + + const html = renderToStaticMarkup() + + expect(html).toContain('accounts.unavailableTitle') + expect(html).not.toContain('account-merge-primary-password') + }) + + it('renders only methods advertised for the primary account', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('accounts.primary.title') + expect(html).toContain('account-merge-primary-password') + expect(html).not.toContain('GitHub') + }) + + it('resumes a secondary-proof intent from a browser callback', () => { + vi.stubGlobal('window', { + location: { + search: '?accountMerge=secondaryProved' + + '&intentId=8f2bb16c-6e11-4e48-a7a4-6be46ecb0902', + assign: vi.fn(), + }, + history: { + replaceState: vi.fn(), + }, + }) + intentState.data = { + id: '8f2bb16c-6e11-4e48-a7a4-6be46ecb0902', + status: 'READY_FOR_PREVIEW', + expiresAt: '2026-07-31T08:00:00Z', + secondaryMethods: [], + } + + const html = renderToStaticMarkup() + + expect(html).toContain('accounts.secondaryBrowserSuccess') + expect(html).toContain('accounts.preview.build') + }) +}) diff --git a/web/src/features/auth/account-merge-wizard.tsx b/web/src/features/auth/account-merge-wizard.tsx new file mode 100644 index 00000000..3fb9154e --- /dev/null +++ b/web/src/features/auth/account-merge-wizard.tsx @@ -0,0 +1,1136 @@ +import { useState } from 'react' +import { + AlertTriangle, + CheckCircle2, + ExternalLink, + KeyRound, + Loader2, + ShieldCheck, +} from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { + ApiError, + buildApiUrl, +} from '@/api/client' +import type { + AccountMergeAuthenticationMethod, + AccountMergeCredentialRequest, + AccountMergePreview, +} from '@/api/types' +import { truncateErrorMessage } from '@/shared/lib/error-display' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { + useAccountMergeActions, + useAccountMergeCapabilities, + useAccountMergeIntent, +} from './use-account-merge' + +interface AccountMergeCallback { + result?: 'primaryProved' | 'secondaryProved' | 'failed' + intentId?: string + phase?: 'PRIMARY_REAUTHENTICATION' | 'SECONDARY_AUTHENTICATION' | 'UNKNOWN' + reasonCode?: string +} + +const accountMergeFailureCodes = new Set([ + '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', +]) + +const accountMergeCallbackPhases = new Set([ + 'PRIMARY_REAUTHENTICATION', + 'SECONDARY_AUTHENTICATION', + 'UNKNOWN', +]) + +const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +export function parseAccountMergeCallback( + search: string, +): AccountMergeCallback { + const params = new URLSearchParams(search) + const result = params.get('accountMerge') + if ( + result !== 'primaryProved' + && result !== 'secondaryProved' + && result !== 'failed' + ) { + return {} + } + const rawIntentId = params.get('intentId') + const intentId = rawIntentId && uuidPattern.test(rawIntentId) + ? rawIntentId + : undefined + const rawPhase = params.get('phase') + const phase = rawPhase && accountMergeCallbackPhases.has(rawPhase) + ? rawPhase as AccountMergeCallback['phase'] + : undefined + const rawReasonCode = params.get('reasonCode') + const reasonCode = rawReasonCode + && accountMergeFailureCodes.has(rawReasonCode) + ? rawReasonCode + : undefined + return { + result, + ...(intentId ? { intentId } : {}), + ...(phase ? { phase } : {}), + ...(reasonCode ? { reasonCode } : {}), + } +} + +export function accountMergeFailureMessageKey( + reasonCode?: string, +): string { + switch (reasonCode) { + case 'ACCOUNT_MERGE_UNAVAILABLE': + return 'accounts.errors.unavailable' + case 'MERGE_INTENT_NOT_FOUND': + case 'MERGE_SESSION_MISMATCH': + case 'MERGE_PROOF_EXPIRED': + case 'MERGE_ALREADY_CONSUMED': + return 'accounts.errors.intentUnavailable' + case 'MERGE_REAUTH_REQUIRED': + return 'accounts.errors.authenticationFailed' + case 'MERGE_PROVIDER_AUTHENTICATION_FAILED': + return 'accounts.errors.providerAuthenticationFailed' + case 'MERGE_PROVIDER_UNAVAILABLE': + return 'accounts.errors.providerUnavailable' + case 'MERGE_CONFLICT': + return 'accounts.errors.conflict' + case 'MERGE_PREVIEW_STALE': + return 'accounts.errors.previewStale' + case 'MERGE_ACCOUNT_NOT_ELIGIBLE': + return 'accounts.errors.accountNotEligible' + default: + return 'accounts.errors.default' + } +} + +function currentCallback(): AccountMergeCallback { + return typeof window === 'undefined' + ? {} + : parseAccountMergeCallback(window.location.search) +} + +function currentIntentId(): string | undefined { + if (typeof window === 'undefined') return undefined + const value = new URLSearchParams(window.location.search) + .get('intentId') + return value && uuidPattern.test(value) ? value : undefined +} + +function replaceIntentLocation(intentId?: string) { + if (typeof window === 'undefined' || !window.history) return + const path = intentId + ? `/settings/accounts?intentId=${encodeURIComponent(intentId)}` + : '/settings/accounts' + window.history.replaceState({}, '', path) +} + +function uniqueMethods( + methods: AccountMergeAuthenticationMethod[], + acceptedTypes: Set, +) { + const result = new Map() + for (const method of methods) { + if ( + acceptedTypes.has(method.methodType) + && !result.has(method.providerCode) + ) { + result.set(method.providerCode, method) + } + } + return [...result.values()] +} + +function errorMessage( + error: unknown, + fallback: string, + translate: (key: string) => string, +) { + if (error instanceof ApiError && error.reasonCode) { + return translate(accountMergeFailureMessageKey(error.reasonCode)) + } + return truncateErrorMessage( + error instanceof Error ? error.message : fallback, + ) ?? fallback +} + +function CredentialFields({ + prefix, + value, + disabled, + onChange, +}: { + prefix: string + value: AccountMergeCredentialRequest + disabled: boolean + onChange: (next: AccountMergeCredentialRequest) => void +}) { + const { t } = useTranslation() + return ( +

+
+ + onChange({ + ...value, + username: event.target.value, + })} + /> +
+
+ + onChange({ + ...value, + password: event.target.value, + })} + /> +
+
+ ) +} + +function PreviewSection({ preview }: { preview: AccountMergePreview }) { + const { t } = useTranslation() + const social = preview.social + const notifications = preview.notifications + + return ( +
+
+
+ {preview.confirmable + ? + : } +
+

+ {preview.confirmable + ? t('accounts.preview.readyTitle') + : t('accounts.preview.blockedTitle')} +

+

+ {preview.confirmable + ? t('accounts.preview.readyDescription') + : t('accounts.preview.blockedDescription')} +

+
+
+
+ + {social.discardedRatings.length > 0 ? ( +
+

+ {t('accounts.preview.discardedRatings')} +

+
    + {social.discardedRatings.map((rating) => ( +
  • + {t('accounts.preview.discardedRating', { + skillId: rating.skillId, + score: rating.score, + })} +
  • + ))} +
+
+ ) : null} + +
+ 0 + ? preview.identityProviders.join(', ') + : t('accounts.preview.none')} + /> + + + +
+ + {preview.namespaceChanges.length > 0 ? ( +
+

+ {t('accounts.preview.namespaces')} +

+
+ {preview.namespaceChanges.map((change) => ( +
+ @{change.namespaceSlug} + + {change.primaryRole ?? t('accounts.preview.none')} + {' + '} + {change.secondaryRole ?? t('accounts.preview.none')} + {' → '} + {change.resultingRole ?? t('accounts.preview.none')} + +
+ ))} +
+
+ ) : null} + + {preview.apiTokensToRevoke.length > 0 ? ( +
+

+ {t('accounts.preview.tokensToRevoke')} +

+
    + {preview.apiTokensToRevoke.map((token) => ( +
  • + {token.name} ({token.prefix}…) +
  • + ))} +
+
+ ) : null} + +
+ + +
+ + {preview.blockedPlatformRoles.length > 0 ? ( + + ) : null} + {preview.conflicts.length > 0 ? ( + t('accounts.preview.conflictItem', { + resource: conflict.resource, + action: t( + `accounts.preview.conflictActions.${conflict.suggestedAction}`, + { defaultValue: conflict.suggestedAction }, + ), + }), + )} + /> + ) : null} +
+ ) +} + +function PreviewValue({ + label, + value, +}: { + label: string + value: string +}) { + return ( +
+

+ {label} +

+

{value}

+
+ ) +} + +function ConflictList({ + title, + items, +}: { + title: string + items: string[] +}) { + return ( +
+

{title}

+
    + {items.map((item) =>
  • {item}
  • )} +
+
+ ) +} + +export function AccountMergeWizard() { + const { t } = useTranslation() + const [callback] = useState(currentCallback) + const [activeIntentId, setActiveIntentId] = useState( + callback.intentId ?? currentIntentId(), + ) + const [primaryPassword, setPrimaryPassword] = useState('') + const [primaryCredentials, setPrimaryCredentials] = + useState({ + username: '', + password: '', + }) + const [secondaryCredentials, setSecondaryCredentials] = + useState({ + username: '', + password: '', + }) + const [acknowledged, setAcknowledged] = useState(false) + const [completed, setCompleted] = useState(false) + const [flowError, setFlowError] = useState( + callback.result === 'failed' + ? t(accountMergeFailureMessageKey(callback.reasonCode)) + : '', + ) + + const capabilitiesQuery = useAccountMergeCapabilities() + const intentQuery = useAccountMergeIntent(activeIntentId) + const actions = useAccountMergeActions() + const preview = actions.preview.data + const capabilities = capabilitiesQuery.data + const intent = intentQuery.data + const allMutations = Object.values(actions) + const isPending = allMutations.some((mutation) => mutation.isPending) + + const primaryMethods = capabilities?.primaryMethods ?? [] + const primaryBrowserMethods = uniqueMethods( + primaryMethods, + new Set(['OAUTH_REDIRECT', 'CAS_REDIRECT']), + ) + const primaryCredentialMethods = uniqueMethods( + primaryMethods, + new Set(['DIRECT_PASSWORD']), + ) + const hasPrimaryLocal = primaryMethods.some( + (method) => method.methodType === 'LOCAL_PASSWORD', + ) + const secondaryMethods = + intent?.secondaryMethods ?? capabilities?.secondaryMethods ?? [] + const secondaryBrowserMethods = uniqueMethods( + secondaryMethods, + new Set(['OAUTH_REDIRECT', 'CAS_REDIRECT']), + ) + const secondaryCredentialMethods = uniqueMethods( + secondaryMethods, + new Set(['DIRECT_PASSWORD']), + ) + const hasSecondaryLocal = secondaryMethods.some( + (method) => method.methodType === 'LOCAL_PASSWORD', + ) + + function activateIntent(intentId: string) { + setActiveIntentId(intentId) + replaceIntentLocation(intentId) + } + + function clearFlow() { + setActiveIntentId(undefined) + actions.preview.reset() + setAcknowledged(false) + setCompleted(false) + setFlowError('') + setPrimaryPassword('') + setPrimaryCredentials({ username: '', password: '' }) + setSecondaryCredentials({ username: '', password: '' }) + replaceIntentLocation() + } + + async function createIntent() { + setFlowError('') + try { + const created = await actions.createIntent.mutateAsync() + activateIntent(created.id) + } catch (error) { + setFlowError(errorMessage( + error, + t('accounts.errors.default'), + t, + )) + } + } + + async function reauthenticatePrimaryLocal( + event: React.FormEvent, + ) { + event.preventDefault() + if (!primaryPassword) { + setFlowError(t('accounts.errors.passwordRequired')) + return + } + setFlowError('') + try { + await actions.reauthenticatePrimaryLocal.mutateAsync(primaryPassword) + setPrimaryPassword('') + await createIntent() + } catch (error) { + setFlowError(errorMessage( + error, + t('accounts.errors.authenticationFailed'), + t, + )) + } + } + + async function reauthenticatePrimaryCredential( + providerCode: string, + ) { + if ( + !primaryCredentials.username.trim() + || !primaryCredentials.password + ) { + setFlowError(t('accounts.errors.credentialsRequired')) + return + } + setFlowError('') + try { + await actions.reauthenticatePrimaryCredential.mutateAsync({ + providerCode, + credentials: { + username: primaryCredentials.username.trim(), + password: primaryCredentials.password, + }, + }) + setPrimaryCredentials({ username: '', password: '' }) + await createIntent() + } catch (error) { + setFlowError(errorMessage( + error, + t('accounts.errors.authenticationFailed'), + t, + )) + } + } + + async function startPrimaryBrowser(providerCode: string) { + setFlowError('') + try { + const actionUrl = + await actions.preparePrimaryBrowser.mutateAsync(providerCode) + window.location.assign(buildApiUrl(actionUrl)) + } catch (error) { + setFlowError(errorMessage( + error, + t('accounts.errors.providerAuthenticationFailed'), + t, + )) + } + } + + async function authenticateSecondaryLocal( + event: React.FormEvent, + ) { + event.preventDefault() + if ( + !activeIntentId + || !secondaryCredentials.username.trim() + || !secondaryCredentials.password + ) { + setFlowError(t('accounts.errors.credentialsRequired')) + return + } + setFlowError('') + try { + await actions.authenticateSecondaryLocal.mutateAsync({ + intentId: activeIntentId, + credentials: { + username: secondaryCredentials.username.trim(), + password: secondaryCredentials.password, + }, + }) + setSecondaryCredentials({ username: '', password: '' }) + } catch (error) { + setFlowError(errorMessage( + error, + t('accounts.errors.authenticationFailed'), + t, + )) + } + } + + async function authenticateSecondaryCredential( + providerCode: string, + ) { + if ( + !activeIntentId + || !secondaryCredentials.username.trim() + || !secondaryCredentials.password + ) { + setFlowError(t('accounts.errors.credentialsRequired')) + return + } + setFlowError('') + try { + await actions.authenticateSecondaryCredential.mutateAsync({ + intentId: activeIntentId, + providerCode, + credentials: { + username: secondaryCredentials.username.trim(), + password: secondaryCredentials.password, + }, + }) + setSecondaryCredentials({ username: '', password: '' }) + } catch (error) { + setFlowError(errorMessage( + error, + t('accounts.errors.authenticationFailed'), + t, + )) + } + } + + async function startSecondaryBrowser(providerCode: string) { + if (!activeIntentId) return + setFlowError('') + try { + const actionUrl = + await actions.prepareSecondaryBrowser.mutateAsync({ + intentId: activeIntentId, + providerCode, + }) + window.location.assign(buildApiUrl(actionUrl)) + } catch (error) { + setFlowError(errorMessage( + error, + t('accounts.errors.providerAuthenticationFailed'), + t, + )) + } + } + + async function buildPreview() { + if (!activeIntentId) return + setFlowError('') + try { + await actions.preview.mutateAsync(activeIntentId) + setAcknowledged(false) + } catch (error) { + setFlowError(errorMessage( + error, + t('accounts.errors.default'), + t, + )) + } + } + + async function confirmMerge() { + if (!activeIntentId || !preview || !acknowledged) return + setFlowError('') + try { + await actions.confirm.mutateAsync({ + intentId: activeIntentId, + previewVersion: preview.previewVersion, + }) + setCompleted(true) + replaceIntentLocation() + } catch (error) { + setAcknowledged(false) + setFlowError(errorMessage( + error, + t('accounts.errors.default'), + t, + )) + } + } + + async function cancelMerge() { + if (!activeIntentId) return + setFlowError('') + try { + await actions.cancel.mutateAsync(activeIntentId) + clearFlow() + } catch (error) { + setFlowError(errorMessage( + error, + t('accounts.errors.default'), + t, + )) + } + } + + if (capabilitiesQuery.isLoading) { + return ( + + ) + } + + if (capabilitiesQuery.error) { + return ( + + ) + } + + if (!capabilities?.enabled) { + return ( + + ) + } + + if (completed || intent?.status === 'COMPLETED') { + return ( + + {t('accounts.done')} + + )} + /> + ) + } + + if ( + intent?.status === 'CANCELLED' + || intent?.status === 'EXPIRED' + ) { + return ( + + {t('accounts.startOver')} + + )} + /> + ) + } + + return ( + + + {t('accounts.title')} + {t('accounts.description')} + + +
+
+ +
+

{t('accounts.warningTitle')}

+

{t('accounts.warningDescription')}

+
+
+
+ + {callback.result === 'primaryProved' && !activeIntentId ? ( +
+ {t('accounts.primaryBrowserSuccess')} +
+ ) : null} + {callback.result === 'secondaryProved' ? ( +
+ {t('accounts.secondaryBrowserSuccess')} +
+ ) : null} + {flowError ? ( +
+ {flowError} +
+ ) : null} + + {!activeIntentId ? ( +
+
+

+ {t('accounts.primary.title')} +

+

+ {t('accounts.primary.description')} +

+
+ + {callback.result === 'primaryProved' ? ( + + ) : ( + <> + {hasPrimaryLocal ? ( +
+
+ +

+ {t('accounts.authentication.localPassword')} +

+
+ + + setPrimaryPassword(event.target.value)} + /> + +
+ ) : null} + + {primaryBrowserMethods.length > 0 ? ( + + void startPrimaryBrowser(method.providerCode)} + /> + ) : null} + + {primaryCredentialMethods.length > 0 ? ( +
+ + + void reauthenticatePrimaryCredential( + method.providerCode, + )} + /> +
+ ) : null} + + {primaryMethods.length === 0 ? ( +

+ {t('accounts.primary.noMethods')} +

+ ) : null} + + )} +
+ ) : intentQuery.isLoading ? ( +
+ + {t('accounts.loadingIntent')} +
+ ) : intentQuery.error ? ( +
+

+ {errorMessage( + intentQuery.error, + t('accounts.errors.intentUnavailable'), + t, + )} +

+ +
+ ) : intent?.status === 'PENDING_SECONDARY_PROOF' ? ( +
+
+

+ {t('accounts.secondary.title')} +

+

+ {t('accounts.secondary.description')} +

+
+ + {hasSecondaryLocal ? ( +
+ + + + ) : null} + + {secondaryBrowserMethods.length > 0 ? ( + + void startSecondaryBrowser(method.providerCode)} + /> + ) : null} + + {secondaryCredentialMethods.length > 0 ? ( +
+ + + void authenticateSecondaryCredential( + method.providerCode, + )} + /> +
+ ) : null} + + +
+ ) : ( +
+
+

+ {t('accounts.preview.title')} +

+

+ {t('accounts.preview.description')} +

+
+ + {preview ? : null} + + {!preview || !preview.confirmable ? ( + + ) : ( +
+ + +
+ )} + + +
+ )} +
+
+ ) +} + +function MethodButtons({ + title, + methods, + disabled, + onSelect, +}: { + title: string + methods: AccountMergeAuthenticationMethod[] + disabled: boolean + onSelect: (method: AccountMergeAuthenticationMethod) => void +}) { + return ( +
+
+ +

{title}

+
+
+ {methods.map((method) => ( + + ))} +
+
+ ) +} + +function LoadingCard({ text }: { text: string }) { + return ( + + + + {text} + + + ) +} + +function MessageCard({ + title, + description, + detail, + action, + destructive = false, +}: { + title: string + description: string + detail?: string + action?: React.ReactNode + destructive?: boolean +}) { + return ( + + + {title} + {description} + + {(detail || action) ? ( + + {detail ? ( +

+ {detail} +

+ ) : null} + {action} +
+ ) : null} +
+ ) +} diff --git a/web/src/features/auth/use-account-merge.test.ts b/web/src/features/auth/use-account-merge.test.ts index a34f41cb..387886a5 100644 --- a/web/src/features/auth/use-account-merge.test.ts +++ b/web/src/features/auth/use-account-merge.test.ts @@ -1,23 +1,19 @@ import { describe, expect, it } from 'vitest' import * as accountMerge from './use-account-merge' -/** - * use-account-merge exports three thin useMutation hooks (useInitiateAccountMerge, - * useVerifyAccountMerge, useConfirmAccountMerge) that delegate directly to accountApi. - * There are no exported pure functions, constants, or data transformations to unit-test. - * - * This file verifies the public API surface so that accidental export removals are caught. - */ describe('use-account-merge module exports', () => { - it('exports useInitiateAccountMerge hook', () => { - expect(accountMerge.useInitiateAccountMerge).toBeTypeOf('function') + it('exports the capability, intent, and action hooks', () => { + expect(accountMerge.useAccountMergeCapabilities).toBeTypeOf('function') + expect(accountMerge.useAccountMergeIntent).toBeTypeOf('function') + expect(accountMerge.useAccountMergeActions).toBeTypeOf('function') }) - it('exports useVerifyAccountMerge hook', () => { - expect(accountMerge.useVerifyAccountMerge).toBeTypeOf('function') - }) - - it('exports useConfirmAccountMerge hook', () => { - expect(accountMerge.useConfirmAccountMerge).toBeTypeOf('function') + it('uses intent-scoped query keys', () => { + expect(accountMerge.accountMergeKeys.intent('intent-1')).toEqual([ + 'auth', + 'account-merge', + 'intent', + 'intent-1', + ]) }) }) diff --git a/web/src/features/auth/use-account-merge.ts b/web/src/features/auth/use-account-merge.ts index c4f75fe8..8d758607 100644 --- a/web/src/features/auth/use-account-merge.ts +++ b/web/src/features/auth/use-account-merge.ts @@ -1,25 +1,159 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { accountApi } from '@/api/client' -import type { MergeConfirmRequest, MergeInitiateRequest, MergeVerifyRequest } from '@/api/types' +import type { + AccountMergeCredentialRequest, + AccountMergeIntent, +} from '@/api/types' -export function useInitiateAccountMerge() { - return useMutation({ - mutationFn: (request: MergeInitiateRequest) => accountApi.initiateMerge(request), +export const accountMergeKeys = { + capabilities: ['auth', 'account-merge', 'capabilities'] as const, + intent: (intentId: string) => + ['auth', 'account-merge', 'intent', intentId] as const, + completion: (intentId: string) => + ['auth', 'account-merge', 'completion', intentId] as const, +} + +export function useAccountMergeCapabilities() { + return useQuery({ + queryKey: accountMergeKeys.capabilities, + queryFn: accountApi.capabilities, + staleTime: 15_000, + retry: false, }) } -export function useVerifyAccountMerge() { - return useMutation({ - mutationFn: (request: MergeVerifyRequest) => accountApi.verifyMerge(request), +export function useAccountMergeIntent(intentId?: string) { + return useQuery({ + queryKey: accountMergeKeys.intent(intentId ?? ''), + queryFn: () => accountApi.getIntent(intentId ?? ''), + enabled: !!intentId, + retry: false, }) } -export function useConfirmAccountMerge() { +export function useAccountMergeActions() { const queryClient = useQueryClient() - return useMutation({ - mutationFn: (request: MergeConfirmRequest) => accountApi.confirmMerge(request), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + + function cacheIntent(intent: AccountMergeIntent) { + queryClient.setQueryData( + accountMergeKeys.intent(intent.id), + intent, + ) + } + + const reauthenticatePrimaryLocal = useMutation({ + mutationFn: accountApi.reauthenticatePrimaryLocal, + }) + const reauthenticatePrimaryCredential = useMutation({ + mutationFn: ({ + providerCode, + credentials, + }: { + providerCode: string + credentials: AccountMergeCredentialRequest + }) => accountApi.reauthenticatePrimaryCredential( + providerCode, + credentials, + ), + }) + const preparePrimaryBrowser = useMutation({ + mutationFn: accountApi.preparePrimaryBrowser, + }) + const createIntent = useMutation({ + mutationFn: accountApi.createIntent, + onSuccess: cacheIntent, + }) + const authenticateSecondaryLocal = useMutation({ + mutationFn: ({ + intentId, + credentials, + }: { + intentId: string + credentials: AccountMergeCredentialRequest + }) => accountApi.authenticateSecondaryLocal( + intentId, + credentials, + ), + onSuccess: cacheIntent, + }) + const authenticateSecondaryCredential = useMutation({ + mutationFn: ({ + intentId, + providerCode, + credentials, + }: { + intentId: string + providerCode: string + credentials: AccountMergeCredentialRequest + }) => accountApi.authenticateSecondaryCredential( + intentId, + providerCode, + credentials, + ), + onSuccess: cacheIntent, + }) + const prepareSecondaryBrowser = useMutation({ + mutationFn: ({ + intentId, + providerCode, + }: { + intentId: string + providerCode: string + }) => accountApi.prepareSecondaryBrowser( + intentId, + providerCode, + ), + }) + const preview = useMutation({ + mutationFn: accountApi.preview, + onSuccess: (result) => { + queryClient.setQueryData( + accountMergeKeys.intent(result.intentId), + (current) => current + ? { ...current, status: result.status } + : current, + ) }, }) + const confirm = useMutation({ + mutationFn: ({ + intentId, + previewVersion, + }: { + intentId: string + previewVersion: number + }) => accountApi.confirm(intentId, previewVersion), + onSuccess: async (completion) => { + queryClient.setQueryData( + accountMergeKeys.completion(completion.intentId), + completion, + ) + queryClient.setQueryData( + accountMergeKeys.intent(completion.intentId), + (current) => current + ? { ...current, status: completion.status } + : current, + ) + await queryClient.invalidateQueries() + }, + }) + const cancel = useMutation({ + mutationFn: accountApi.cancel, + onSuccess: (intent) => { + cacheIntent(intent) + }, + }) + + return { + reauthenticatePrimaryLocal, + reauthenticatePrimaryCredential, + preparePrimaryBrowser, + createIntent, + authenticateSecondaryLocal, + authenticateSecondaryCredential, + prepareSecondaryBrowser, + preview, + confirm, + cancel, + } } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 361501bb..58b4e963 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -819,9 +819,99 @@ } }, "accounts": { + "title": "Merge accounts", + "description": "Move eligible login methods and current data from another account into this account after independently verifying both accounts.", + "loading": "Loading account merge capabilities...", + "loadingIntent": "Loading the protected merge request...", + "errorTitle": "Account merge could not be loaded", "unavailableTitle": "Account merging is temporarily unavailable", "unavailableDescription": "The previous flow could not independently verify control of both accounts, so it has been disabled during a security redesign.", - "unavailableOperatorAction": "Do not ask an administrator to complete a merge in the database. Keep using the accounts separately until the secure flow is available." + "unavailableOperatorAction": "Do not ask an administrator to complete a merge in the database. Keep using the accounts separately until the secure flow is available.", + "warningTitle": "This operation is permanent", + "warningDescription": "The secondary account will no longer be usable, its API tokens will be revoked, and a completed merge cannot be undone automatically.", + "primaryBrowserSuccess": "This account was freshly verified. Continue now to create the protected merge request.", + "secondaryBrowserSuccess": "The other account was independently verified. Review the migration plan before confirming.", + "working": "Working...", + "completedTitle": "Accounts merged", + "completedDescription": "Eligible data was moved to this account. The secondary account and its existing sessions and API tokens can no longer be used.", + "done": "Done", + "intentUnavailableTitle": "Merge request unavailable", + "startOver": "Start over", + "cancel": "Cancel merge", + "confirm": "Permanently merge accounts", + "confirmAcknowledgement": "I reviewed the migration plan and understand that this merge is permanent and revokes the secondary account's API tokens and sessions.", + "primary": { + "title": "1. Verify this account", + "description": "Your current session is not enough. Use one login method already linked to this account for fresh verification.", + "continue": "Create protected merge request", + "noMethods": "This account has no login method that supports fresh verification. Link a supported login method before merging." + }, + "secondary": { + "title": "2. Independently verify the other account", + "description": "Sign in to the account you want to merge. Do not enter an email address, user ID, or provider subject as a substitute for authentication.", + "verifyLocal": "Verify other local account" + }, + "authentication": { + "username": "Username", + "password": "Password", + "localPassword": "Local password", + "currentPassword": "Current password", + "verifyAndContinue": "Verify and continue", + "browserMethods": "Verify in a browser", + "credentialMethods": "Verify with provider credentials" + }, + "preview": { + "title": "3. Review the migration plan", + "description": "The server recalculates this plan from current data. Any later change makes an earlier preview stale.", + "readyTitle": "The merge can be confirmed", + "readyDescription": "No blocking permission or ownership conflict was found. Review every item before confirming.", + "blockedTitle": "Resolve conflicts before merging", + "blockedDescription": "No data was moved. Resolve the listed conflicts, then refresh the preview.", + "identityProviders": "Identity providers to move", + "localCredential": "Local credential action", + "localCredentialActions": { + "NONE": "No local credential change", + "MOVE_SECONDARY": "Move the secondary local credential", + "KEEP_PRIMARY_DELETE_SECONDARY": "Keep this account's credential and invalidate the secondary credential" + }, + "skills": "Skills changing owner", + "tokens": "API tokens to revoke", + "namespaces": "Namespace membership changes", + "tokensToRevoke": "Secondary API tokens that will be revoked", + "social": "Stars, ratings, and subscriptions", + "socialSummary": "{{stars}} stars, {{ratings}} ratings, and {{subscriptions}} subscriptions move; {{duplicates}} duplicate records are discarded.", + "discardedRatings": "Secondary ratings that will be discarded", + "discardedRating": "Skill #{{skillId}}: discard score {{score}} and retain this account's rating.", + "notifications": "Notifications and preferences", + "notificationSummary": "{{notifications}} notifications, {{preferences}} preferences, and {{governance}} governance notifications move; {{duplicates}} duplicate preferences are discarded.", + "blockedRoles": "Blocking platform roles", + "conflicts": "Blocking conflicts", + "conflictItem": "{{resource}} — {{action}}", + "conflictActions": { + "REMOVE_DUPLICATE_IDENTITY": "remove one conflicting login identity", + "REMOVE_SECONDARY_PLATFORM_ROLE": "remove the secondary account's elevated platform role", + "TRANSFER_NAMESPACE_OWNERSHIP": "transfer namespace ownership before retrying", + "REASSIGN_OR_RENAME_SKILL": "reassign or rename the conflicting Skill", + "COMPLETE_OR_CANCEL_IDENTITY_LINK": "complete or cancel the active identity-link request", + "COMPLETE_OR_CANCEL_PROFILE_CHANGE": "complete or cancel the pending profile change" + }, + "none": "None", + "build": "Build migration preview", + "refresh": "Refresh migration preview" + }, + "errors": { + "unavailable": "Secure account merging is not enabled on this deployment.", + "intentUnavailable": "This merge request expired, was cancelled, belongs to another session, or was already consumed. Start again.", + "authenticationFailed": "Account verification failed. Check the credentials and account status, then try again.", + "providerAuthenticationFailed": "The identity provider did not verify the account. No merge data was changed.", + "providerUnavailable": "This identity provider is not currently available for account verification.", + "conflict": "The accounts have a blocking permission, identity, ownership, or workflow conflict.", + "previewStale": "The account data changed after this preview. Build and review a new preview.", + "accountNotEligible": "One of the accounts is not eligible for merging.", + "passwordRequired": "Enter your current password.", + "credentialsRequired": "Enter both username and password.", + "default": "The account merge request could not be completed. No partial merge was applied." + } }, "namespace": { "notFound": "Namespace not found", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 92de7c1d..f2046735 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -819,9 +819,99 @@ } }, "accounts": { + "title": "合并账号", + "description": "分别验证两个账号后,将另一个账号中允许迁移的登录方式和当前数据合并到本账号。", + "loading": "正在加载账号合并能力……", + "loadingIntent": "正在加载受保护的合并请求……", + "errorTitle": "无法加载账号合并功能", "unavailableTitle": "账号合并暂时不可用", "unavailableDescription": "旧流程无法分别证明两个账号的控制权,因此在安全重构完成前已被停用。", - "unavailableOperatorAction": "请勿让管理员通过数据库手工完成合并。在安全流程上线前,请继续分别使用两个账号。" + "unavailableOperatorAction": "请勿让管理员通过数据库手工完成合并。在安全流程上线前,请继续分别使用两个账号。", + "warningTitle": "此操作永久生效", + "warningDescription": "次账号将不能继续使用,其 API Token 会被撤销;合并完成后不能由系统自动拆分。", + "primaryBrowserSuccess": "本账号已完成重新验证。请立即继续创建受保护的合并请求。", + "secondaryBrowserSuccess": "另一个账号已独立验证成功。最终确认前请检查迁移计划。", + "working": "处理中……", + "completedTitle": "账号合并完成", + "completedDescription": "允许迁移的数据已归入本账号。次账号及其原有 Session 和 API Token 均不能继续使用。", + "done": "完成", + "intentUnavailableTitle": "合并请求不可用", + "startOver": "重新开始", + "cancel": "取消合并", + "confirm": "永久合并账号", + "confirmAcknowledgement": "我已检查迁移计划,并理解本次合并不可自动撤销,且会撤销次账号的 API Token 和 Session。", + "primary": { + "title": "1. 验证本账号", + "description": "当前登录 Session 不能代替重新认证。请使用本账号已关联的一种登录方式重新验证。", + "continue": "创建受保护的合并请求", + "noMethods": "本账号没有支持重新验证的登录方式。请先关联一种受支持的登录方式。" + }, + "secondary": { + "title": "2. 独立验证另一个账号", + "description": "请实际登录要合并的账号,不能用邮箱、用户 ID 或 Provider Subject 代替身份认证。", + "verifyLocal": "验证另一个本地账号" + }, + "authentication": { + "username": "用户名", + "password": "密码", + "localPassword": "本地密码", + "currentPassword": "当前密码", + "verifyAndContinue": "验证并继续", + "browserMethods": "通过浏览器验证", + "credentialMethods": "使用身份提供方凭据验证" + }, + "preview": { + "title": "3. 检查迁移计划", + "description": "服务端会根据当前数据重新计算计划。预览之后的任何相关数据变化都会使旧预览失效。", + "readyTitle": "当前可以确认合并", + "readyDescription": "未发现阻塞性的权限或归属冲突。确认前请逐项检查。", + "blockedTitle": "解决冲突后才能合并", + "blockedDescription": "当前没有迁移任何数据。请先解决下列冲突,再刷新预览。", + "identityProviders": "将迁移的身份提供方", + "localCredential": "本地凭据处理方式", + "localCredentialActions": { + "NONE": "不变更本地凭据", + "MOVE_SECONDARY": "将次账号本地凭据迁移到主账号", + "KEEP_PRIMARY_DELETE_SECONDARY": "保留主账号凭据并使次账号凭据失效" + }, + "skills": "将变更所有者的 Skill", + "tokens": "将撤销的 API Token", + "namespaces": "命名空间成员关系变化", + "tokensToRevoke": "将撤销的次账号 API Token", + "social": "Star、评分和订阅", + "socialSummary": "迁移 {{stars}} 个 Star、{{ratings}} 个评分和 {{subscriptions}} 个订阅;丢弃 {{duplicates}} 条重复记录。", + "discardedRatings": "将丢弃的次账号评分", + "discardedRating": "Skill #{{skillId}}:丢弃 {{score}} 分,保留主账号现有评分。", + "notifications": "通知和偏好", + "notificationSummary": "迁移 {{notifications}} 条通知、{{preferences}} 条偏好和 {{governance}} 条治理通知;丢弃 {{duplicates}} 条重复偏好。", + "blockedRoles": "阻塞合并的平台角色", + "conflicts": "阻塞冲突", + "conflictItem": "{{resource}} — {{action}}", + "conflictActions": { + "REMOVE_DUPLICATE_IDENTITY": "先移除其中一个冲突登录身份", + "REMOVE_SECONDARY_PLATFORM_ROLE": "先移除次账号的高权限平台角色", + "TRANSFER_NAMESPACE_OWNERSHIP": "先完成命名空间所有权转移", + "REASSIGN_OR_RENAME_SKILL": "先重新分配或重命名冲突 Skill", + "COMPLETE_OR_CANCEL_IDENTITY_LINK": "先完成或取消进行中的身份关联请求", + "COMPLETE_OR_CANCEL_PROFILE_CHANGE": "先完成或取消待处理的资料变更" + }, + "none": "无", + "build": "生成迁移预览", + "refresh": "刷新迁移预览" + }, + "errors": { + "unavailable": "当前部署尚未启用安全账号合并。", + "intentUnavailable": "本次合并请求已过期、已取消、属于其他 Session 或已被消费,请重新开始。", + "authenticationFailed": "账号验证失败。请检查凭据和账号状态后重试。", + "providerAuthenticationFailed": "身份提供方未能验证账号,本次没有变更任何合并数据。", + "providerUnavailable": "当前身份提供方暂不能用于账号验证。", + "conflict": "两个账号存在阻塞性的权限、身份、归属或工作流冲突。", + "previewStale": "生成预览后账号数据已变化,请重新生成并检查预览。", + "accountNotEligible": "其中一个账号当前不符合合并条件。", + "passwordRequired": "请输入当前密码。", + "credentialsRequired": "请输入用户名和密码。", + "default": "账号合并请求未能完成,系统没有执行部分合并。" + } }, "namespace": { "notFound": "命名空间不存在", diff --git a/web/src/pages/settings/accounts.test.ts b/web/src/pages/settings/accounts.test.ts index d09e2456..70b4e9c3 100644 --- a/web/src/pages/settings/accounts.test.ts +++ b/web/src/pages/settings/accounts.test.ts @@ -1,30 +1,20 @@ -/** @vitest-environment jsdom */ - -import { render, screen } from '@testing-library/react' +import { renderToStaticMarkup } from 'react-dom/server' import { createElement } from 'react' import { describe, expect, it, vi } from 'vitest' -vi.mock('react-i18next', async () => { - const actual = await vi.importActual('react-i18next') - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => key, - }), - } -}) +vi.mock('@/features/auth/account-merge-wizard', () => ({ + AccountMergeWizard: () => + createElement('div', null, 'account-merge-wizard'), +})) import { AccountSettingsPage } from './accounts' describe('AccountSettingsPage', () => { - it('shows the temporary isolation notice without legacy merge controls', () => { - const { container } = render(createElement(AccountSettingsPage)) + it('renders the secure account merge wizard', () => { + const html = renderToStaticMarkup( + createElement(AccountSettingsPage), + ) - expect(screen.getByText('accounts.unavailableTitle')).toBeTruthy() - expect(screen.getByText('accounts.unavailableDescription')).toBeTruthy() - expect(screen.getByText('accounts.unavailableOperatorAction')).toBeTruthy() - expect(container.querySelector('form')).toBeNull() - expect(container.querySelector('input')).toBeNull() - expect(container.querySelector('button')).toBeNull() + expect(html).toContain('account-merge-wizard') }) }) diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx index e97b0483..54d1e12b 100644 --- a/web/src/pages/settings/accounts.tsx +++ b/web/src/pages/settings/accounts.tsx @@ -1,27 +1,9 @@ -import { useTranslation } from 'react-i18next' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { AccountMergeWizard } from '@/features/auth/account-merge-wizard' -/** - * Account merge is intentionally unavailable until the platform can prove independent control of - * both accounts. Keep the settings route so existing links remain valid, but do not render any - * legacy identifier, token, verification, or confirmation controls. - */ export function AccountSettingsPage() { - const { t } = useTranslation() - return (
- - - {t('accounts.unavailableTitle')} - {t('accounts.unavailableDescription')} - - -

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

-
-
+
) }