From 9557478dd8d9d757b84b13e5ceefb13b4ced0646 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:02:17 +0800 Subject: [PATCH] feat(auth): add explicit identity link and safe unlink flow Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .github/workflows/pr-e2e.yml | 27 + docs/21-unified-identity-federation-design.md | 136 +- .../identity-binding-v2-postgres-test.sh | 7 +- scripts/tests/oauth2-mock-provider.py | 236 ++++ server/skillhub-app/pom.xml | 4 + .../controller/IdentityLinkController.java | 309 +++++ .../IdentityLinkMutationResponses.java | 69 + .../skillhub/dto/ApiResponseFactory.java | 30 + .../dto/CreateIdentityLinkRequest.java | 17 + .../dto/CreateIdentityUnlinkRequest.java | 11 + .../dto/IdentityLinkAccountStateResponse.java | 14 + .../dto/IdentityLinkBindingResponse.java | 17 + .../dto/IdentityLinkBrowserStartRequest.java | 17 + .../dto/IdentityLinkBrowserStartResponse.java | 6 + .../dto/IdentityLinkCredentialRequest.java | 21 + .../dto/IdentityLinkErrorResponse.java | 33 + .../dto/IdentityLinkIntentResponse.java | 16 + ...ntityLinkLocalReauthenticationRequest.java | 9 + .../dto/IdentityLinkProviderResponse.java | 14 + .../IdentityLinkTargetCredentialRequest.java | 11 + .../exception/GlobalExceptionHandler.java | 89 +- .../security/ApiAccessDeniedHandler.java | 31 +- .../security/ApiAuthenticationEntryPoint.java | 19 +- .../service/IdentityLinkAppService.java | 347 +++++ .../migration/V49__identity_link_request.sql | 91 ++ .../src/main/resources/messages.properties | 22 + .../src/main/resources/messages_zh.properties | 22 + ...MigrationLoginPostgresIntegrationTest.java | 325 +++++ .../IdentityLinkMigrationPostgresTest.java | 303 ++++ .../IdentityLinkPostgresIntegrationTest.java | 877 ++++++++++++ .../IdentityLinkControllerTest.java | 261 ++++ .../controller/LocalAuthControllerTest.java | 21 +- .../NamespaceBatchMemberControllerTest.java | 5 +- .../exception/GlobalExceptionHandlerTest.java | 35 + .../security/ApiAccessDeniedHandlerTest.java | 23 + .../ApiAuthenticationEntryPointTest.java | 94 ++ .../IdentityLinkRouteRequestMatcher.java | 26 + .../skillhub/auth/config/SecurityConfig.java | 16 +- .../skillhub/auth/entity/IdentityBinding.java | 32 +- .../auth/entity/IdentityBindingSubject.java | 9 + .../auth/entity/IdentityLinkOperation.java | 6 + .../auth/entity/IdentityLinkRequest.java | 232 ++++ .../entity/IdentityLinkRequestStatus.java | 13 + .../DefaultExternalIdentityLinkService.java | 132 ++ .../identity/ExternalIdentityLinkService.java | 22 + .../identity/IdentityLinkAccountState.java | 14 + .../auth/identity/IdentityLinkActor.java | 73 + .../identity/IdentityLinkBindingView.java | 29 + .../identity/IdentityLinkBrowserFlow.java | 10 + .../identity/IdentityLinkBrowserPhase.java | 6 + .../auth/identity/IdentityLinkException.java | 26 + .../identity/IdentityLinkFailureCode.java | 63 + .../auth/identity/IdentityLinkIntent.java | 24 + .../identity/IdentityLinkIntentService.java | 152 ++ .../auth/identity/IdentityLinkOutcome.java | 28 + .../identity/IdentityLinkProviderView.java | 21 + .../identity/IdentityLinkSessionManager.java | 286 ++++ .../identity/IdentityLinkStateHasher.java | 44 + .../identity/IdentityLinkTransaction.java | 917 +++++++++++++ .../IdentityResolutionTransaction.java | 5 +- .../skillhub/auth/local/LocalAuthService.java | 38 +- .../auth/local/LocalCredentialRepository.java | 22 + .../IdentityProviderRouteReadinessFilter.java | 25 +- .../auth/oauth/OAuth2LoginFailureHandler.java | 71 +- .../auth/oauth/OAuthLoginFlowService.java | 157 ++- ...HubOAuth2AuthorizationRequestResolver.java | 25 +- .../repository/IdentityBindingRepository.java | 9 +- .../IdentityLinkRequestRepository.java | 39 + .../auth/entity/IdentityLinkRequestTest.java | 95 ++ .../IdentityLinkIntentServiceTest.java | 123 ++ .../IdentityLinkSessionManagerTest.java | 209 +++ .../IdentityResolutionTransactionTest.java | 33 +- .../auth/local/LocalAuthServiceTest.java | 92 +- ...ntityProviderRouteReadinessFilterTest.java | 33 +- ...Auth2AuthorizationRequestResolverTest.java | 103 +- .../auth/oauth/OAuth2LoginHandlersTest.java | 133 +- .../auth/oauth/OAuthLoginFlowServiceTest.java | 142 +- web/e2e/settings-security-capability.spec.ts | 141 +- web/playwright.config.ts | 14 +- web/src/api/client.test.ts | 136 +- web/src/api/client.ts | 376 ++++- web/src/api/generated/schema.d.ts | 1221 +++++++++++++++++ web/src/api/types.ts | 51 + .../auth/identity-link-manager.test.tsx | 205 +++ .../features/auth/identity-link-manager.tsx | 781 +++++++++++ web/src/features/auth/use-identity-links.ts | 132 ++ web/src/i18n/locales/en.json | 48 +- web/src/i18n/locales/zh.json | 48 +- web/src/pages/settings/security.test.tsx | 5 + web/src/pages/settings/security.tsx | 10 +- web/src/shared/lib/api-error.ts | 1 + web/vite.config.ts | 11 +- 92 files changed, 10162 insertions(+), 122 deletions(-) create mode 100644 scripts/tests/oauth2-mock-provider.py create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/IdentityLinkController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/IdentityLinkMutationResponses.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateIdentityLinkRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateIdentityUnlinkRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkAccountStateResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBindingResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBrowserStartRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBrowserStartResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkCredentialRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkErrorResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkIntentResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkLocalReauthenticationRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkProviderResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkTargetCredentialRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V49__identity_link_request.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkMigrationLoginPostgresIntegrationTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkMigrationPostgresTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkPostgresIntegrationTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/IdentityLinkControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPointTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/IdentityLinkRouteRequestMatcher.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkOperation.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequestStatus.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLinkService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityLinkService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkAccountState.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkActor.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBindingView.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserFlow.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserPhase.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkException.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkFailureCode.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntent.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkOutcome.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkProviderView.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkStateHasher.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityLinkRequestRepository.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequestTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentServiceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManagerTest.java create mode 100644 web/src/features/auth/identity-link-manager.test.tsx create mode 100644 web/src/features/auth/identity-link-manager.tsx create mode 100644 web/src/features/auth/use-identity-links.ts diff --git a/.github/workflows/pr-e2e.yml b/.github/workflows/pr-e2e.yml index c8e9a3b9..edbdce40 100644 --- a/.github/workflows/pr-e2e.yml +++ b/.github/workflows/pr-e2e.yml @@ -65,13 +65,33 @@ jobs: - name: Verify Binding V2 migration and concurrency on PostgreSQL run: bash scripts/tests/identity-binding-v2-postgres-test.sh + - name: Start loopback identity provider for Identity Link + run: | + python3 scripts/tests/oauth2-mock-provider.py --port 18081 \ + > /tmp/skillhub-oauth2-mock.log 2>&1 & + echo "$!" > /tmp/skillhub-oauth2-mock.pid + for attempt in $(seq 1 30); do + if curl --fail --silent \ + http://127.0.0.1:18081/health >/dev/null; then + exit 0 + fi + sleep 1 + done + exit 1 + - name: Start full dev stack + env: + OAUTH2_GITLAB_BASE_URI: http://127.0.0.1:18081 + OAUTH2_GITLAB_CLIENT_ID: skillhub-e2e + OAUTH2_GITLAB_CLIENT_SECRET: skillhub-e2e-secret run: make dev-all - name: Install Playwright browsers run: cd web && pnpm exec playwright install --with-deps chromium - name: Run frontend E2E tests + env: + E2E_IDENTITY_LINK_BROWSER_PROVIDER: gitlab run: make test-e2e-frontend - name: Upload Playwright HTML report @@ -95,3 +115,10 @@ jobs: - name: Stop full dev stack if: ${{ always() }} run: make dev-all-down + + - name: Stop loopback identity provider + if: ${{ always() }} + run: | + if [[ -f /tmp/skillhub-oauth2-mock.pid ]]; then + kill "$(cat /tmp/skillhub-oauth2-mock.pid)" 2>/dev/null || true + fi diff --git a/docs/21-unified-identity-federation-design.md b/docs/21-unified-identity-federation-design.md index 657a998c..2d96cb50 100644 --- a/docs/21-unified-identity-federation-design.md +++ b/docs/21-unified-identity-federation-design.md @@ -1379,15 +1379,24 @@ PRIMARY KEY(user_id, field_name) ```text id uuid PK primary_user_id varchar(128) NOT NULL +operation LINK / UNLINK provider_code varchar(64) NOT NULL -state_hash varchar(128) NOT NULL -status PENDING / COMPLETED / EXPIRED / CANCELLED +target_binding_id bigint NULL +state_hash char(64) NOT NULL +status PENDING_REAUTHENTICATION / READY / + COMPLETED / EXPIRED / CANCELLED +reauthentication_method varchar(96) +reauthenticated_at timestamptz expires_at timestamptz NOT NULL created_at timestamptz NOT NULL +updated_at timestamptz NOT NULL completed_at timestamptz +cancelled_at timestamptz ``` -只保存 hash 和流程元数据,不保存 OAuth code、CAS Ticket 或密码。 +只保存 hash 和流程元数据,不保存 OAuth code、CAS Ticket 或密码。`provider_code` +保留创建 intent 时的历史值,不对 `identity_provider_state` 建外键:配置已删除或 +Authority 不可用的历史 Binding 仍必须能在账号还有其他登录方式时安全解绑。 ### 10.6 SCIM 资源绑定 @@ -1576,14 +1585,21 @@ PROVIDER_AUTHORITATIVE 9. 写审计并消费 link request。 ``` -未来 Interface: +当前 Interface: ```java public interface ExternalIdentityLinkService { + IdentityLinkOutcome reauthenticate( + IdentityLinkActor actor, + UUID intentId, + ResolvedProviderHandle provider, + ProviderAuthenticationResult result + ); + IdentityLinkOutcome link( - AuthenticatedActor actor, - IdentityLinkIntent intent, + IdentityLinkActor actor, + UUID intentId, ResolvedProviderHandle provider, ProviderAuthenticationResult result ); @@ -1593,6 +1609,108 @@ public interface ExternalIdentityLinkService { Link Facade 内部复用同一个 descriptor source、Authority Lock 和 package-private Assertion Factory;Provider 仍不能构造 `IdentityAssertion`。 +#### 12.2.1 Identity Link 实现契约 + +[#655](https://github.com/iflytek/skillhub/issues/655) 实现上述 Link/Unlink 核心。 +HTTP、Session 和 Provider 协议 I/O 位于事务外;身份核心拥有 intent 状态检查、 +Provider capability 检查、Binding/Subject 唯一性、账号资格和审计规则。 + +服务端状态机: + +| 状态 | 允许操作 | 下一状态 | +|---|---|---| +| `PENDING_REAUTHENTICATION` | 当前账号 fresh reauthentication | `READY` | +| `READY` + `LINK` | 独立认证目标 Provider 并创建 Binding V2 | `COMPLETED` | +| `READY` + `UNLINK` | 检查仍有其他可用登录方式并撤销 Binding/Subjects | `COMPLETED` | +| active intent | 取消 | `CANCELLED` | +| active intent 超过 10 分钟 TTL | 任意读取或消费 | `EXPIRED` | +| `COMPLETED/CANCELLED/EXPIRED` | 重放 | 拒绝,不再改变状态 | + +每个 intent 同时绑定: + +- 当前 `userId`; +- 当前 Platform Session 中 256-bit 随机 nonce;数据库只保存 SHA-256; +- 操作类型、目标 Provider、可选目标 Binding; +- 固定过期时间和一次性状态。 + +当前账号证明与目标 Provider 证明必须分开。Browser Provider 使用现有 OAuth state +校验并保留主 Platform Session;Credential Provider 只在 Adapter 中校验凭据, +只把 `ProviderAuthenticationResult` 交给核心。密码、OAuth code/token、ticket、 +Cookie、原始 Session ID/nonce 和 proof 不进入 DTO、数据库、审计或日志。 + +公开 API: + +| 方法 | 路径 | 作用 | +|---|---|---| +| `GET` | `/api/v1/auth/identity-links` | 列出当前账号已绑定和可添加的登录方式;不返回外部 Subject | +| `POST` | `/api/v1/auth/identity-link-intents/link` | 创建 Link intent | +| `POST` | `/api/v1/auth/identity-link-intents/unlink` | 创建 Unlink intent | +| `GET` / `DELETE` | `/api/v1/auth/identity-link-intents/{intentId}` | 查看或取消 intent | +| `POST` | `.../{intentId}/reauthenticate/local` | 用本地密码重新认证当前账号 | +| `POST` | `.../{intentId}/reauthenticate/browser` | 发起 Browser Provider 当前账号证明 | +| `POST` | `.../{intentId}/reauthenticate/credential` | 用 Credential Provider 证明当前账号 | +| `POST` | `.../{intentId}/link/browser` | 发起目标 Browser Provider 认证 | +| `POST` | `.../{intentId}/link/credential` | 认证并绑定目标 Credential Provider | +| `POST` | `.../{intentId}/unlink` | fresh reauthentication 后完成解绑 | + +REST 失败响应除 HTTP status 和本地化 `msg` 外,必须返回稳定 `reasonCode`。Browser +回调失败通过 `/settings/security?identityLink=failed&intentId=...&reasonCode=...` +返回同一 allowlist 中的 code。当前 allowlist: + +```text +INTENT_NOT_FOUND +REAUTHENTICATION_REQUIRED +SESSION_MISMATCH +INTENT_EXPIRED +ALREADY_CONSUMED +ACTIVE_INTENT_EXISTS +ACCOUNT_NOT_ELIGIBLE +PROVIDER_UNAVAILABLE +PROVIDER_AUTHENTICATION_FAILED +ALREADY_LINKED +IDENTITY_IN_USE +FINAL_LOGIN_METHOD +INVALID_OPERATION +``` + +前端账号安全页只使用生成的 OpenAPI `paths/components` 和 `openapi-fetch` 调用这些 +端点,并由 TanStack Query 管理服务端状态。Callback 只接受固定格式的 reason code, +显示本地化安全提示;不得显示外部 Subject 或原始 Provider 错误。 + +#### 12.2.2 模块与持久化边界 + +`IdentityLinkAppService` 只编排 Session context、Provider 协议调用和响应映射。 +intent 状态、operation、Provider capability、账号资格、Binding/Subject 唯一性和 +最后登录方式保护位于 `skillhub-auth` identity core。 + +`identity_link_request`、Identity Binding V2 和 Subject 是认证 bounded context 的 +安全状态,因此 Entity 和 Spring Data Repository 保留在 `skillhub-auth`。这是相对 +通用 domain-port/infra-implementation 规则的显式例外:它们不属于 SkillHub 业务 +Domain Aggregate,且必须与认证事务、pessimistic lock 和 Provider Authority Lock +共同演进。App 和 Provider Adapter 不得直接写这些表。 + +#### 12.2.3 升级、滚动发布和回滚 + +V49 是向后兼容的 expand migration:增加 `identity_link_request`,并把旧的全量唯一 +约束替换为 ACTIVE-only 部分唯一索引;它不重写已有 Binding。升级测试必须从 V48 +数据库执行 V49,并验证旧 ACTIVE Binding 仍能登录。 + +解绑后允许同一 Subject 重新绑定,同时保留 REVOKED Binding/Subject 历史。因此一旦 +生产数据发生“解绑后重绑”,会存在同一 `provider + legacy subject` 的一条 ACTIVE 和 +一条或多条 REVOKED 记录。V49-aware 代码只读取 ACTIVE 记录;V49 之前使用无状态单行 +查询的运行时可能得到非唯一结果。 + +发布约束: + +1. 多实例部署必须先让所有认证实例升级到 V49-aware 版本,再向用户开放 Link/Unlink + 入口;不能在旧、新认证实例混跑时允许完成解绑或重绑。 +2. 部署期间若不能保证上述顺序,应暂时在网关隐藏新增端点和账号安全入口,完成升级后 + 再开放。 +3. V49 migration 本身可与旧数据共存;首次 Link/Unlink 写入之后,安全回滚下限提升为 + V49-aware 版本。不得回滚到使用无状态 legacy Binding 单行查询的版本。 +4. 回滚只允许代码回滚到 V49-aware 版本,不回滚或删除 V49 表和 REVOKED 历史。 +5. 发布前必须在 PostgreSQL 16 上验证 V48 → V49、并发消费、解绑后重绑和旧账号登录。 + ### 12.3 解绑 解绑前必须: @@ -2366,7 +2484,7 @@ PR 4:Provider Registry + Adapter 契约冻结 - disabled/misconfigured Provider 不出现在目录且不联网。 - 旧前端登录目录 API 保持兼容。 -#### PR 5:显式 Identity Link +#### PR 5:显式 Identity Link(#655) 范围: @@ -2380,6 +2498,10 @@ PR 4:Provider Registry + Adapter 契约冻结 - 无法只凭 email 或 username 创建 Binding。 - Link 重放、过期、并发消费和最后登录方式测试通过。 +- JSON API 和 Browser callback 都返回 allowlist 内的稳定 reason code。 +- PostgreSQL 16 验证同一 intent 并发消费只能成功一次。 +- 以准确 `big-main` SHA 构建镜像,在隔离测试环境验证升级、回归和真实浏览器链路后, + 才能进入 `main`。 #### PR 6:安全 Account Merge diff --git a/scripts/tests/identity-binding-v2-postgres-test.sh b/scripts/tests/identity-binding-v2-postgres-test.sh index 30f0d698..754133d3 100755 --- a/scripts/tests/identity-binding-v2-postgres-test.sh +++ b/scripts/tests/identity-binding-v2-postgres-test.sh @@ -130,5 +130,8 @@ run_test() { run_test IdentityBindingV2MigrationPostgresTest run_test IdentityBindingV2ContractPostgresTest 47 run_test UserProfileFieldSourceMigrationPostgresTest -run_test IdentityBindingV2PostgresIntegrationTest 48 -run_test IdentityProfileProvisioningPostgresIntegrationTest 48 +run_test IdentityBindingV2PostgresIntegrationTest +run_test IdentityProfileProvisioningPostgresIntegrationTest +run_test IdentityLinkMigrationPostgresTest +run_test IdentityLinkMigrationLoginPostgresIntegrationTest +run_test IdentityLinkPostgresIntegrationTest diff --git a/scripts/tests/oauth2-mock-provider.py b/scripts/tests/oauth2-mock-provider.py new file mode 100644 index 00000000..39c265e9 --- /dev/null +++ b/scripts/tests/oauth2-mock-provider.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Minimal loopback-only GitLab OAuth provider for browser E2E tests.""" + +from __future__ import annotations + +import argparse +import json +import secrets +import threading +import time +from http import HTTPStatus +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + + +class OAuthState: + def __init__(self) -> None: + self.lock = threading.Lock() + self.code_subjects: dict[str, str] = {} + self.token_subjects: dict[str, str] = {} + + def issue_code(self, subject: str) -> str: + code = secrets.token_urlsafe(24) + with self.lock: + self.code_subjects[code] = subject + return code + + def exchange(self, code: str) -> str | None: + with self.lock: + subject = self.code_subjects.pop(code, None) + if subject is None: + return None + token = secrets.token_urlsafe(24) + self.token_subjects[token] = subject + return token + + def subject_for_token(self, token: str) -> str | None: + with self.lock: + return self.token_subjects.get(token) + + +STATE = OAuthState() + + +class OAuthHandler(BaseHTTPRequestHandler): + server_version = "SkillHubOAuthMock/1.0" + + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path == "/health": + self.send_json(HTTPStatus.OK, {"status": "UP"}) + return + if parsed.path == "/oauth/authorize": + self.authorize(parse_qs(parsed.query)) + return + if parsed.path == "/api/v4/user": + self.user_info() + return + if parsed.path == "/api/v4/user/emails": + self.user_emails() + return + self.send_error(HTTPStatus.NOT_FOUND) + + def do_POST(self) -> None: # noqa: N802 + if urlparse(self.path).path != "/oauth/token": + self.send_error(HTTPStatus.NOT_FOUND) + return + length = int(self.headers.get("Content-Length", "0")) + form = parse_qs( + self.rfile.read(length).decode("utf-8"), + keep_blank_values=True, + ) + code = first(form, "code") + token = STATE.exchange(code) + if token is None: + self.send_json( + HTTPStatus.BAD_REQUEST, + { + "error": "invalid_grant", + "error_description": "Unknown authorization code", + }, + ) + return + self.send_json( + HTTPStatus.OK, + { + "access_token": token, + "token_type": "Bearer", + "expires_in": 300, + "created_at": int(time.time()), + }, + ) + + def authorize(self, query: dict[str, list[str]]) -> None: + redirect_uri = first(query, "redirect_uri") + state = first(query, "state") + if not safe_callback(redirect_uri): + self.send_json( + HTTPStatus.BAD_REQUEST, + {"error": "invalid_redirect_uri"}, + ) + return + subject = self.subject_cookie() + created_subject = subject is None + if subject is None: + subject = str(10**11 + secrets.randbelow(9 * 10**11)) + code = STATE.issue_code(subject) + parsed = urlparse(redirect_uri) + callback_query = parse_qs( + parsed.query, + keep_blank_values=True, + ) + callback_query["code"] = [code] + callback_query["state"] = [state] + location = urlunparse( + parsed._replace(query=urlencode(callback_query, doseq=True)) + ) + self.send_response(HTTPStatus.FOUND) + if created_subject: + self.send_header( + "Set-Cookie", + "skillhub_mock_subject=" + + subject + + "; Path=/; HttpOnly; SameSite=Lax", + ) + self.send_header("Location", location) + self.end_headers() + + def user_info(self) -> None: + subject = self.authenticated_subject() + if subject is None: + return + self.send_json( + HTTPStatus.OK, + { + "id": int(subject), + "username": "mock_gitlab_" + subject, + "name": "Mock GitLab User", + "email": subject + "@gitlab.example.test", + "confirmed_at": "2026-07-31T00:00:00Z", + "avatar_url": None, + }, + ) + + def user_emails(self) -> None: + subject = self.authenticated_subject() + if subject is None: + return + self.send_json( + HTTPStatus.OK, + [ + { + "email": subject + "@gitlab.example.test", + "confirmed_at": "2026-07-31T00:00:00Z", + } + ], + ) + + def authenticated_subject(self) -> str | None: + authorization = self.headers.get("Authorization", "") + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + self.send_json( + HTTPStatus.UNAUTHORIZED, + {"error": "invalid_token"}, + ) + return None + subject = STATE.subject_for_token(token) + if subject is None: + self.send_json( + HTTPStatus.UNAUTHORIZED, + {"error": "invalid_token"}, + ) + return subject + + def subject_cookie(self) -> str | None: + cookie = SimpleCookie() + cookie.load(self.headers.get("Cookie", "")) + morsel = cookie.get("skillhub_mock_subject") + if morsel is None or not morsel.value.isdecimal(): + return None + return morsel.value + + def send_json( + self, + status: HTTPStatus, + body: object, + ) -> None: + payload = json.dumps(body).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message( + self, + format_string: str, + *args: object, + ) -> None: + del format_string, args + + +def first(values: dict[str, list[str]], key: str) -> str: + candidates = values.get(key) + return candidates[0] if candidates else "" + + +def safe_callback(value: str) -> bool: + parsed = urlparse(value) + return ( + parsed.scheme == "http" + and parsed.hostname in {"127.0.0.1", "localhost"} + and parsed.path == "/login/oauth2/code/gitlab" + and parsed.username is None + and parsed.password is None + and parsed.fragment == "" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, required=True) + args = parser.parse_args() + server = ThreadingHTTPServer( + ("127.0.0.1", args.port), + OAuthHandler, + ) + print(f"LISTENING_PORT={server.server_port}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index aeeee7a3..40c5fa15 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -18,6 +18,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-validation + org.springframework.boot spring-boot-starter-actuator diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/IdentityLinkController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/IdentityLinkController.java new file mode 100644 index 00000000..c21c506c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/IdentityLinkController.java @@ -0,0 +1,309 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.CreateIdentityLinkRequest; +import com.iflytek.skillhub.dto.CreateIdentityUnlinkRequest; +import com.iflytek.skillhub.dto.IdentityLinkAccountStateResponse; +import com.iflytek.skillhub.dto.IdentityLinkBrowserStartRequest; +import com.iflytek.skillhub.dto.IdentityLinkBrowserStartResponse; +import com.iflytek.skillhub.dto.IdentityLinkCredentialRequest; +import com.iflytek.skillhub.dto.IdentityLinkIntentResponse; +import com.iflytek.skillhub.dto.IdentityLinkLocalReauthenticationRequest; +import com.iflytek.skillhub.dto.IdentityLinkTargetCredentialRequest; +import com.iflytek.skillhub.exception.UnauthorizedException; +import com.iflytek.skillhub.ratelimit.RateLimit; +import com.iflytek.skillhub.service.IdentityLinkAppService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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.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; + +/** + * Transport endpoints for explicit external identity link and safe unlink + * workflows. + */ +@RestController +@RequestMapping("/api/v1/auth") +@Tag( + name = "Identity Link", + description = "Manage login methods with fresh reauthentication") +public class IdentityLinkController extends BaseApiController { + + private final IdentityLinkAppService identityLinkAppService; + + public IdentityLinkController( + ApiResponseFactory responseFactory, + IdentityLinkAppService identityLinkAppService) { + super(responseFactory); + this.identityLinkAppService = identityLinkAppService; + } + + @Operation( + summary = "List linked and available login methods", + description = "Returns active external bindings and providers that can be linked.") + @IdentityLinkMutationResponses + @GetMapping("/identity-links") + public ApiResponse accountState( + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) { + requirePrincipal(principal); + return ok( + "response.success.read", + identityLinkAppService.accountState( + principal.userId(), + requireSession(request))); + } + + @Operation(summary = "Create an external identity link intent") + @IdentityLinkMutationResponses + @PostMapping("/identity-link-intents/link") + @RateLimit( + category = "identity-link-create", + authenticated = 10, + anonymous = 1, + windowSeconds = 300) + public ApiResponse createLinkIntent( + @Valid @RequestBody CreateIdentityLinkRequest body, + HttpServletRequest request) { + return ok( + "response.success.created", + identityLinkAppService.createLinkIntent( + body.providerCode(), + requireSession(request), + context(request))); + } + + @Operation(summary = "Create an external identity unlink intent") + @IdentityLinkMutationResponses + @PostMapping("/identity-link-intents/unlink") + @RateLimit( + category = "identity-unlink-create", + authenticated = 10, + anonymous = 1, + windowSeconds = 300) + public ApiResponse createUnlinkIntent( + @Valid @RequestBody CreateIdentityUnlinkRequest body, + HttpServletRequest request) { + return ok( + "response.success.created", + identityLinkAppService.createUnlinkIntent( + body.bindingId(), + requireSession(request), + context(request))); + } + + @Operation(summary = "Get an identity link intent") + @IdentityLinkMutationResponses + @GetMapping("/identity-link-intents/{intentId}") + public ApiResponse getIntent( + @PathVariable UUID intentId, + HttpServletRequest request) { + return ok( + "response.success.read", + identityLinkAppService.getIntent( + intentId, + requireSession(request), + context(request))); + } + + @Operation(summary = "Cancel an identity link intent") + @IdentityLinkMutationResponses + @DeleteMapping("/identity-link-intents/{intentId}") + public ApiResponse cancel( + @PathVariable UUID intentId, + HttpServletRequest request) { + return ok( + "response.success.updated", + identityLinkAppService.cancel( + intentId, + requireSession(request), + context(request))); + } + + @Operation(summary = "Freshly reauthenticate with the local password") + @IdentityLinkMutationResponses + @PostMapping( + "/identity-link-intents/{intentId}" + + "/reauthenticate/local") + @RateLimit( + category = "identity-link-local-reauth", + authenticated = 5, + anonymous = 1, + windowSeconds = 300) + public ApiResponse reauthenticateLocal( + @PathVariable UUID intentId, + @Valid @RequestBody + IdentityLinkLocalReauthenticationRequest body, + HttpServletRequest request) { + return ok( + "response.success.updated", + identityLinkAppService.reauthenticateLocal( + intentId, + body.password(), + requireSession(request), + context(request))); + } + + @Operation(summary = "Start browser-provider fresh reauthentication") + @IdentityLinkMutationResponses + @PostMapping( + "/identity-link-intents/{intentId}" + + "/reauthenticate/browser") + @RateLimit( + category = "identity-link-browser-reauth", + authenticated = 10, + anonymous = 1, + windowSeconds = 300) + public ApiResponse + prepareBrowserReauthentication( + @PathVariable UUID intentId, + @Valid @RequestBody + IdentityLinkBrowserStartRequest body, + HttpServletRequest request) { + return ok( + "response.success.created", + new IdentityLinkBrowserStartResponse( + identityLinkAppService + .prepareBrowserReauthentication( + intentId, + body.providerCode(), + requireSession(request), + context(request)))); + } + + @Operation(summary = "Freshly reauthenticate with a credential provider") + @IdentityLinkMutationResponses + @PostMapping( + "/identity-link-intents/{intentId}" + + "/reauthenticate/credential") + @RateLimit( + category = "identity-link-credential-reauth", + authenticated = 5, + anonymous = 1, + windowSeconds = 300) + public ApiResponse + reauthenticateCredential( + @PathVariable UUID intentId, + @Valid @RequestBody + IdentityLinkCredentialRequest body, + HttpServletRequest request) { + return ok( + "response.success.updated", + identityLinkAppService.reauthenticateCredential( + intentId, + body.providerCode(), + body.username(), + body.password(), + requireSession(request), + context(request))); + } + + @Operation(summary = "Start browser authentication for the target identity") + @IdentityLinkMutationResponses + @PostMapping( + "/identity-link-intents/{intentId}/link/browser") + @RateLimit( + category = "identity-link-browser-target", + authenticated = 10, + anonymous = 1, + windowSeconds = 300) + public ApiResponse + prepareBrowserLink( + @PathVariable UUID intentId, + HttpServletRequest request) { + return ok( + "response.success.created", + new IdentityLinkBrowserStartResponse( + identityLinkAppService.prepareBrowserLink( + intentId, + requireSession(request), + context(request)))); + } + + @Operation(summary = "Authenticate and link a credential-provider identity") + @IdentityLinkMutationResponses + @PostMapping( + "/identity-link-intents/{intentId}" + + "/link/credential") + @RateLimit( + category = "identity-link-credential-target", + authenticated = 5, + anonymous = 1, + windowSeconds = 300) + public ApiResponse linkCredential( + @PathVariable UUID intentId, + @Valid @RequestBody + IdentityLinkTargetCredentialRequest body, + HttpServletRequest request) { + return ok( + "response.success.updated", + identityLinkAppService.linkCredential( + intentId, + body.username(), + body.password(), + requireSession(request), + context(request))); + } + + @Operation(summary = "Complete unlink after fresh reauthentication") + @IdentityLinkMutationResponses + @PostMapping( + "/identity-link-intents/{intentId}/unlink") + public ApiResponse completeUnlink( + @PathVariable UUID intentId, + HttpServletRequest request) { + return ok( + "response.success.updated", + identityLinkAppService.completeUnlink( + intentId, + requireSession(request), + context(request))); + } + + private void requirePrincipal(PlatformPrincipal principal) { + if (principal == null) { + throw new UnauthorizedException( + "error.auth.required"); + } + } + + 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/IdentityLinkMutationResponses.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/IdentityLinkMutationResponses.java new file mode 100644 index 00000000..6813043b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/IdentityLinkMutationResponses.java @@ -0,0 +1,69 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.dto.IdentityLinkErrorResponse; +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; + +@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 identity link operation", + content = @Content( + schema = @Schema( + implementation = + IdentityLinkErrorResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "Fresh reauthentication required", + content = @Content( + schema = @Schema( + implementation = + IdentityLinkErrorResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "Intent belongs to another session", + content = @Content( + schema = @Schema( + implementation = + IdentityLinkErrorResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Identity Link intent was not found", + content = @Content( + schema = @Schema( + implementation = + IdentityLinkErrorResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "Identity conflict, consumed intent, or final login method", + content = @Content( + schema = @Schema( + implementation = + IdentityLinkErrorResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "410", + description = "Intent expired", + content = @Content( + schema = @Schema( + implementation = + IdentityLinkErrorResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "503", + description = "Identity provider unavailable", + content = @Content( + schema = @Schema( + implementation = + IdentityLinkErrorResponse.class))) +}) +public @interface IdentityLinkMutationResponses { +} 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 720958f4..78630823 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 @@ -32,4 +32,34 @@ public class ApiResponseFactory { public ApiResponse errorMessage(int code, String msg) { return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId")); } + + public IdentityLinkErrorResponse identityLinkError( + int code, + String messageCode, + String reasonCode, + Object... args) { + String msg = messageSource.getMessage( + messageCode, + args, + messageCode, + LocaleContextHolder.getLocale()); + return new IdentityLinkErrorResponse( + code, + msg, + reasonCode, + Instant.now(clock), + MDC.get("requestId")); + } + + public IdentityLinkErrorResponse identityLinkErrorMessage( + int code, + String message, + String reasonCode) { + return new IdentityLinkErrorResponse( + code, + message, + reasonCode, + Instant.now(clock), + MDC.get("requestId")); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateIdentityLinkRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateIdentityLinkRequest.java new file mode 100644 index 00000000..80ad108b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateIdentityLinkRequest.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record CreateIdentityLinkRequest( + @NotBlank(message = "{validation.auth.identityLink.provider.notBlank}") + @Size( + max = 64, + message = "{validation.auth.identityLink.provider.size}") + @Pattern( + regexp = "[a-z0-9][a-z0-9._-]{0,63}", + message = "{validation.auth.identityLink.provider.invalid}") + String providerCode +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateIdentityUnlinkRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateIdentityUnlinkRequest.java new file mode 100644 index 00000000..96807c6d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateIdentityUnlinkRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record CreateIdentityUnlinkRequest( + @NotNull(message = "{validation.auth.identityLink.binding.required}") + @Positive(message = "{validation.auth.identityLink.binding.positive}") + Long bindingId +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkAccountStateResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkAccountStateResponse.java new file mode 100644 index 00000000..4d3e1ea9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkAccountStateResponse.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +public record IdentityLinkAccountStateResponse( + boolean localPasswordEnabled, + List linkedProviders, + List availableProviders +) { + public IdentityLinkAccountStateResponse { + linkedProviders = List.copyOf(linkedProviders); + availableProviders = List.copyOf(availableProviders); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBindingResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBindingResponse.java new file mode 100644 index 00000000..ea067d51 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBindingResponse.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType; +import java.util.Set; + +public record IdentityLinkBindingResponse( + long bindingId, + String providerCode, + String displayName, + Set methodTypes, + boolean usable, + boolean canUnlink +) { + public IdentityLinkBindingResponse { + methodTypes = Set.copyOf(methodTypes); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBrowserStartRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBrowserStartRequest.java new file mode 100644 index 00000000..f113f761 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBrowserStartRequest.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record IdentityLinkBrowserStartRequest( + @NotBlank(message = "{validation.auth.identityLink.provider.notBlank}") + @Size( + max = 64, + message = "{validation.auth.identityLink.provider.size}") + @Pattern( + regexp = "[a-z0-9][a-z0-9._-]{0,63}", + message = "{validation.auth.identityLink.provider.invalid}") + String providerCode +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBrowserStartResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBrowserStartResponse.java new file mode 100644 index 00000000..394b09b4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkBrowserStartResponse.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +public record IdentityLinkBrowserStartResponse( + String actionUrl +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkCredentialRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkCredentialRequest.java new file mode 100644 index 00000000..4a5230ba --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkCredentialRequest.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record IdentityLinkCredentialRequest( + @NotBlank(message = "{validation.auth.identityLink.provider.notBlank}") + @Size( + max = 64, + message = "{validation.auth.identityLink.provider.size}") + @Pattern( + regexp = "[a-z0-9][a-z0-9._-]{0,63}", + message = "{validation.auth.identityLink.provider.invalid}") + String providerCode, + @NotBlank(message = "{validation.auth.identityLink.username.notBlank}") + String username, + @NotBlank(message = "{validation.auth.identityLink.password.notBlank}") + String password +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkErrorResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkErrorResponse.java new file mode 100644 index 00000000..ffaed7ad --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkErrorResponse.java @@ -0,0 +1,33 @@ +package com.iflytek.skillhub.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; + +/** + * Stable machine-readable error envelope for Identity Link operations. + */ +public record IdentityLinkErrorResponse( + int code, + String msg, + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + allowableValues = { + "INTENT_NOT_FOUND", + "REAUTHENTICATION_REQUIRED", + "SESSION_MISMATCH", + "INTENT_EXPIRED", + "ALREADY_CONSUMED", + "ACTIVE_INTENT_EXISTS", + "ACCOUNT_NOT_ELIGIBLE", + "PROVIDER_UNAVAILABLE", + "PROVIDER_AUTHENTICATION_FAILED", + "ALREADY_LINKED", + "IDENTITY_IN_USE", + "FINAL_LOGIN_METHOD", + "INVALID_OPERATION" + }) + String reasonCode, + Instant timestamp, + String requestId +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkIntentResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkIntentResponse.java new file mode 100644 index 00000000..84832dfa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkIntentResponse.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.auth.entity.IdentityLinkOperation; +import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus; +import java.time.Instant; +import java.util.UUID; + +public record IdentityLinkIntentResponse( + UUID id, + IdentityLinkOperation operation, + IdentityLinkRequestStatus status, + String providerCode, + Long targetBindingId, + Instant expiresAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkLocalReauthenticationRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkLocalReauthenticationRequest.java new file mode 100644 index 00000000..24d444da --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkLocalReauthenticationRequest.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record IdentityLinkLocalReauthenticationRequest( + @NotBlank(message = "{validation.auth.identityLink.password.notBlank}") + String password +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkProviderResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkProviderResponse.java new file mode 100644 index 00000000..ab2a3c66 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkProviderResponse.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType; +import java.util.Set; + +public record IdentityLinkProviderResponse( + String providerCode, + String displayName, + Set methodTypes +) { + public IdentityLinkProviderResponse { + methodTypes = Set.copyOf(methodTypes); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkTargetCredentialRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkTargetCredentialRequest.java new file mode 100644 index 00000000..7af17c2f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityLinkTargetCredentialRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record IdentityLinkTargetCredentialRequest( + @NotBlank(message = "{validation.auth.identityLink.username.notBlank}") + String username, + @NotBlank(message = "{validation.auth.identityLink.password.notBlank}") + String password +) { +} 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 2d30bd54..2df2ada8 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,9 +1,13 @@ package com.iflytek.skillhub.exception; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.config.IdentityLinkRouteRequestMatcher; +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.IdentityLinkErrorResponse; import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; import com.iflytek.skillhub.domain.shared.exception.LocalizedMessage; import com.iflytek.skillhub.metrics.SkillHubMetrics; @@ -44,22 +48,74 @@ public class GlobalExceptionHandler { } @ExceptionHandler(LocalizedException.class) - public ResponseEntity> handleLocalizedError(LocalizedException ex, HttpServletRequest request) { + public ResponseEntity handleLocalizedError( + LocalizedException ex, + HttpServletRequest request) { + if (IdentityLinkRouteRequestMatcher.matches(request) + && ex.status() == HttpStatus.UNAUTHORIZED) { + logHandledException( + ex.status(), + ex.messageCode(), + request); + return ResponseEntity.status(ex.status()).body( + apiResponseFactory.identityLinkError( + ex.status().value(), + ex.messageCode(), + IdentityLinkFailureCode + .REAUTHENTICATION_REQUIRED + .name(), + ex.messageArgs())); + } return renderLocalizedError(ex, ex.status(), request); } @ExceptionHandler(AuthFlowException.class) - public ResponseEntity> handleAuthFlowException(AuthFlowException ex, HttpServletRequest request) { + public ResponseEntity handleAuthFlowException( + AuthFlowException ex, + HttpServletRequest request) { + if (IdentityLinkRouteRequestMatcher.matches(request)) { + IdentityLinkFailureCode reasonCode = + ex.getStatus() == HttpStatus.BAD_REQUEST + ? IdentityLinkFailureCode + .INVALID_OPERATION + : IdentityLinkFailureCode + .REAUTHENTICATION_REQUIRED; + logHandledException( + ex.getStatus(), + ex.messageCode(), + request); + return ResponseEntity.status(ex.getStatus()).body( + apiResponseFactory.identityLinkError( + ex.getStatus().value(), + ex.messageCode(), + reasonCode.name(), + ex.messageArgs())); + } return renderLocalizedError(ex, ex.getStatus(), request); } + @ExceptionHandler(IdentityLinkException.class) + public ResponseEntity handleIdentityLinkException( + IdentityLinkException ex, + HttpServletRequest request) { + logHandledException(ex.getStatus(), ex.messageCode(), request); + return ResponseEntity.status(ex.getStatus()).body( + apiResponseFactory.identityLinkError( + 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); } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) { + public ResponseEntity handleValidation( + MethodArgumentNotValidException ex, + HttpServletRequest request) { String msg = ex.getBindingResult().getFieldErrors().stream() .findFirst() .map(FieldError::getDefaultMessage) @@ -68,6 +124,18 @@ public class GlobalExceptionHandler { .map(error -> error.getDefaultMessage()) .orElse(null)); logHandledException(HttpStatus.BAD_REQUEST, "validation.request.invalid", request); + if (IdentityLinkRouteRequestMatcher.matches(request)) { + String message = msg == null || msg.isBlank() + ? "Invalid identity link operation" + : msg; + return ResponseEntity.badRequest().body( + apiResponseFactory.identityLinkErrorMessage( + 400, + message, + IdentityLinkFailureCode + .INVALID_OPERATION + .name())); + } if (msg == null || msg.isBlank()) { return ResponseEntity.badRequest().body(apiResponseFactory.error(400, "error.badRequest")); } @@ -75,8 +143,21 @@ public class GlobalExceptionHandler { } @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleBadRequest(IllegalArgumentException ex, HttpServletRequest request) { + public ResponseEntity handleBadRequest( + IllegalArgumentException ex, + HttpServletRequest request) { logHandledException(HttpStatus.BAD_REQUEST, "error.badRequest", request); + if (IdentityLinkRouteRequestMatcher.matches(request)) { + return ResponseEntity.badRequest().body( + apiResponseFactory.identityLinkError( + 400, + IdentityLinkFailureCode + .INVALID_OPERATION + .messageCode(), + IdentityLinkFailureCode + .INVALID_OPERATION + .name())); + } return ResponseEntity.badRequest().body( apiResponseFactory.error(400, "error.badRequest")); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index 2c930aa6..0d498bcb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -1,11 +1,13 @@ package com.iflytek.skillhub.security; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.config.IdentityLinkRouteRequestMatcher; +import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; import com.iflytek.skillhub.auth.token.ApiTokenAccessDeniedException; -import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -14,8 +16,6 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; -import java.io.IOException; - /** * Converts authorization failures on API routes into the platform's standard JSON error envelope. */ @@ -51,13 +51,24 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler { accessDeniedException.getClass().getSimpleName(), apiTokenException != null ? apiTokenException.getMessage() : null ); - ApiResponse body = apiTokenException != null - ? apiResponseFactory.error( - 403, - apiTokenException.getMessageCode(), - apiTokenException.getMessageArgs() - ) - : apiResponseFactory.error(403, "error.forbidden"); + Object body; + if (apiTokenException != null) { + body = apiResponseFactory.error( + 403, + apiTokenException.getMessageCode(), + apiTokenException.getMessageArgs()); + } else if (IdentityLinkRouteRequestMatcher.matches(request)) { + body = apiResponseFactory.identityLinkError( + 403, + IdentityLinkFailureCode.SESSION_MISMATCH + .messageCode(), + IdentityLinkFailureCode.SESSION_MISMATCH + .name()); + } else { + body = apiResponseFactory.error( + 403, + "error.forbidden"); + } response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(MediaType.APPLICATION_JSON_VALUE); objectMapper.writeValue(response.getOutputStream(), body); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java index 8f5de8d2..2452db1c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java @@ -1,10 +1,12 @@ package com.iflytek.skillhub.security; import com.fasterxml.jackson.databind.ObjectMapper; -import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.auth.config.IdentityLinkRouteRequestMatcher; +import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -13,8 +15,6 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; -import java.io.IOException; - /** * Converts unauthenticated API access attempts into a consistent JSON 401 response. */ @@ -45,7 +45,18 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { sensitiveLogSanitizer.sanitizeRequestTarget(request), authException.getClass().getSimpleName() ); - ApiResponse body = apiResponseFactory.error(401, "error.auth.required"); + Object body = IdentityLinkRouteRequestMatcher.matches(request) + ? apiResponseFactory.identityLinkError( + 401, + IdentityLinkFailureCode + .REAUTHENTICATION_REQUIRED + .messageCode(), + IdentityLinkFailureCode + .REAUTHENTICATION_REQUIRED + .name()) + : apiResponseFactory.error( + 401, + "error.auth.required"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(MediaType.APPLICATION_JSON_VALUE); objectMapper.writeValue(response.getOutputStream(), body); 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 new file mode 100644 index 00000000..b570aae3 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityLinkAppService.java @@ -0,0 +1,347 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus; +import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService; +import com.iflytek.skillhub.auth.identity.IdentityLinkAccountState; +import com.iflytek.skillhub.auth.identity.IdentityLinkActor; +import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase; +import com.iflytek.skillhub.auth.identity.IdentityLinkException; +import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityLinkIntent; +import com.iflytek.skillhub.auth.identity.IdentityLinkIntentService; +import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome; +import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager; +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.provider.CredentialAuthenticationRequest; +import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.dto.IdentityLinkAccountStateResponse; +import com.iflytek.skillhub.dto.IdentityLinkBindingResponse; +import com.iflytek.skillhub.dto.IdentityLinkIntentResponse; +import com.iflytek.skillhub.dto.IdentityLinkProviderResponse; +import jakarta.servlet.http.HttpSession; +import java.util.UUID; +import org.springframework.stereotype.Service; + +/** + * Application orchestration for explicit link/unlink workflows. Protocol I/O + * completes before the identity-core transaction is entered. + */ +@Service +public class IdentityLinkAppService { + + private final IdentityLinkIntentService intentService; + private final ExternalIdentityLinkService externalLinkService; + private final IdentityProviderRegistry providerRegistry; + private final IdentityLinkSessionManager sessionManager; + + public IdentityLinkAppService( + IdentityLinkIntentService intentService, + ExternalIdentityLinkService externalLinkService, + IdentityProviderRegistry providerRegistry, + IdentityLinkSessionManager sessionManager) { + this.intentService = intentService; + this.externalLinkService = externalLinkService; + this.providerRegistry = providerRegistry; + this.sessionManager = sessionManager; + } + + public IdentityLinkAccountStateResponse accountState( + String userId, + HttpSession session) { + PlatformPrincipal sessionPrincipal = + (PlatformPrincipal) session + .getAttribute("platformPrincipal"); + if (!sessionPrincipal.userId().equals(userId)) { + throw new IdentityLinkException( + IdentityLinkFailureCode.SESSION_MISMATCH); + } + IdentityLinkAccountState state = + intentService.accountState(userId); + return new IdentityLinkAccountStateResponse( + state.localPasswordEnabled(), + state.linkedProviders().stream() + .map(binding -> + new IdentityLinkBindingResponse( + binding.bindingId(), + binding.providerCode(), + binding.displayName(), + binding.methodTypes(), + binding.usable(), + binding.canUnlink())) + .toList(), + state.availableProviders().stream() + .map(provider -> + new IdentityLinkProviderResponse( + provider.providerCode(), + provider.displayName(), + provider.methodTypes())) + .toList()); + } + + public IdentityLinkIntentResponse createLinkIntent( + String providerCode, + HttpSession session, + IdentityLoginContext context) { + UUID intentId = UUID.randomUUID(); + IdentityLinkActor actor = sessionManager.start( + session, + intentId, + context); + try { + return toResponse(intentService.createLinkIntent( + actor, + intentId, + providerCode)); + } catch (RuntimeException exception) { + sessionManager.remove(session, intentId); + throw exception; + } + } + + public IdentityLinkIntentResponse createUnlinkIntent( + long bindingId, + HttpSession session, + IdentityLoginContext context) { + UUID intentId = UUID.randomUUID(); + IdentityLinkActor actor = sessionManager.start( + session, + intentId, + context); + try { + return toResponse(intentService.createUnlinkIntent( + actor, + intentId, + bindingId)); + } catch (RuntimeException exception) { + sessionManager.remove(session, intentId); + throw exception; + } + } + + public IdentityLinkIntentResponse getIntent( + UUID intentId, + HttpSession session, + IdentityLoginContext context) { + try { + return toResponse(intentService.getIntent( + sessionManager.actor( + session, + intentId, + context), + intentId)); + } catch (IdentityLinkException exception) { + if (exception.getReasonCode() + == IdentityLinkFailureCode.INTENT_EXPIRED + || exception.getReasonCode() + == IdentityLinkFailureCode.ALREADY_CONSUMED) { + sessionManager.remove(session, intentId); + } + throw exception; + } + } + + public IdentityLinkIntentResponse cancel( + UUID intentId, + HttpSession session, + IdentityLoginContext context) { + IdentityLinkIntentResponse response = toResponse( + intentService.cancel( + sessionManager.actor( + session, + intentId, + context), + intentId)); + sessionManager.remove(session, intentId); + return response; + } + + public IdentityLinkIntentResponse reauthenticateLocal( + UUID intentId, + String password, + HttpSession session, + IdentityLoginContext context) { + return toResponse(intentService.reauthenticateLocal( + sessionManager.actor( + session, + intentId, + context), + intentId, + password)); + } + + public String prepareBrowserReauthentication( + UUID intentId, + String providerCode, + HttpSession session, + IdentityLoginContext context) { + IdentityLinkActor actor = sessionManager.actor( + session, + intentId, + context); + IdentityLinkIntent intent = + intentService.prepareExternalReauthentication( + actor, + intentId, + providerCode, + IdentityProviderLoginMethodType.OAUTH_REDIRECT); + sessionManager.prepareBrowserFlow( + session, + intentId, + IdentityLinkBrowserPhase.REAUTHENTICATE, + providerCode, + context); + return browserAuthorizationUrl( + providerCode, + "/settings/security?identityLink=reauthenticated" + + "&intentId=" + + intentId); + } + + public String prepareBrowserLink( + UUID intentId, + HttpSession session, + IdentityLoginContext context) { + IdentityLinkActor actor = sessionManager.actor( + session, + intentId, + context); + IdentityLinkIntent intent = intentService.prepareExternalLink( + actor, + intentId, + IdentityProviderLoginMethodType.OAUTH_REDIRECT); + sessionManager.prepareBrowserFlow( + session, + intentId, + IdentityLinkBrowserPhase.LINK, + intent.providerCode(), + context); + return browserAuthorizationUrl( + intent.providerCode(), + "/settings/security?identityLink=linked" + + "&intentId=" + + intentId); + } + + public IdentityLinkIntentResponse reauthenticateCredential( + UUID intentId, + String providerCode, + String username, + String password, + HttpSession session, + IdentityLoginContext context) { + IdentityLinkActor actor = sessionManager.actor( + session, + intentId, + context); + intentService.prepareExternalReauthentication( + actor, + intentId, + providerCode, + IdentityProviderLoginMethodType.DIRECT_PASSWORD); + IdentityProviderRegistry.CredentialRoute route = + providerRegistry.requireCredentialRoute(providerCode); + IdentityLinkOutcome outcome = externalLinkService.reauthenticate( + actor, + intentId, + route.provider(), + authenticate(route, username, password)); + if (!(outcome instanceof IdentityLinkOutcome.Reauthenticated)) { + throw new IllegalStateException( + "Credential reauthentication returned an invalid outcome"); + } + return toResponse(intentService.getIntent( + actor, + intentId)); + } + + public IdentityLinkIntentResponse linkCredential( + UUID intentId, + String username, + String password, + HttpSession session, + IdentityLoginContext context) { + IdentityLinkActor actor = sessionManager.actor( + session, + intentId, + context); + IdentityLinkIntent intent = intentService.prepareExternalLink( + actor, + intentId, + IdentityProviderLoginMethodType.DIRECT_PASSWORD); + IdentityProviderRegistry.CredentialRoute route = + providerRegistry.requireCredentialRoute( + intent.providerCode()); + IdentityLinkOutcome outcome = externalLinkService.link( + actor, + intentId, + route.provider(), + authenticate(route, username, password)); + if (!(outcome instanceof IdentityLinkOutcome.Linked)) { + throw new IllegalStateException( + "Credential link returned an invalid outcome"); + } + sessionManager.remove(session, intentId); + return new IdentityLinkIntentResponse( + intent.id(), + intent.operation(), + IdentityLinkRequestStatus.COMPLETED, + intent.providerCode(), + intent.targetBindingId(), + intent.expiresAt()); + } + + public IdentityLinkIntentResponse completeUnlink( + UUID intentId, + HttpSession session, + IdentityLoginContext context) { + IdentityLinkIntentResponse response = toResponse( + intentService.completeUnlink( + sessionManager.actor( + session, + intentId, + context), + intentId)); + sessionManager.remove(session, intentId); + return response; + } + + private ProviderAuthenticationResult authenticate( + IdentityProviderRegistry.CredentialRoute route, + String username, + String password) { + try { + return route.adapter().authenticate( + new CredentialAuthenticationRequest( + username, + password)); + } catch (ProviderAuthenticationException exception) { + throw ProviderAuthenticationFailureMapper.map(exception); + } + } + + private String browserAuthorizationUrl( + String providerCode, + String returnTo) { + return "/oauth2/authorization/" + + providerCode + + "?returnTo=" + + java.net.URLEncoder.encode( + returnTo, + java.nio.charset.StandardCharsets.UTF_8); + } + + private IdentityLinkIntentResponse toResponse( + IdentityLinkIntent intent) { + return new IdentityLinkIntentResponse( + intent.id(), + intent.operation(), + intent.status(), + intent.providerCode(), + intent.targetBindingId(), + intent.expiresAt()); + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V49__identity_link_request.sql b/server/skillhub-app/src/main/resources/db/migration/V49__identity_link_request.sql new file mode 100644 index 00000000..90c6916f --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V49__identity_link_request.sql @@ -0,0 +1,91 @@ +CREATE TABLE identity_link_request ( + id UUID PRIMARY KEY, + primary_user_id VARCHAR(128) NOT NULL, + operation VARCHAR(16) NOT NULL, + provider_code VARCHAR(64) NOT NULL, + target_binding_id BIGINT, + state_hash VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + reauthentication_method VARCHAR(96), + reauthenticated_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + 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_identity_link_request_user + FOREIGN KEY (primary_user_id) + REFERENCES user_account(id) + ON DELETE CASCADE, + CONSTRAINT fk_identity_link_request_binding + FOREIGN KEY (target_binding_id) + REFERENCES identity_binding(id), + CONSTRAINT chk_identity_link_request_operation + CHECK (operation IN ('LINK', 'UNLINK')), + CONSTRAINT chk_identity_link_request_state_hash + CHECK (state_hash ~ '^[0-9a-f]{64}$'), + CONSTRAINT chk_identity_link_request_status + CHECK ( + status IN ( + 'PENDING_REAUTHENTICATION', + 'READY', + 'COMPLETED', + 'EXPIRED', + 'CANCELLED' + ) + ), + CONSTRAINT chk_identity_link_request_target + CHECK ( + (operation = 'LINK' AND target_binding_id IS NULL) + OR + (operation = 'UNLINK' AND target_binding_id IS NOT NULL) + ), + CONSTRAINT chk_identity_link_request_reauthentication + CHECK ( + ( + status = 'PENDING_REAUTHENTICATION' + AND reauthentication_method IS NULL + AND reauthenticated_at IS NULL + ) + OR + ( + status IN ('READY', 'COMPLETED') + AND reauthentication_method IS NOT NULL + AND reauthenticated_at IS NOT NULL + ) + OR + status IN ('EXPIRED', 'CANCELLED') + ), + CONSTRAINT chk_identity_link_request_completion + CHECK ( + (status = 'COMPLETED' AND completed_at IS NOT NULL) + OR + (status <> 'COMPLETED' AND completed_at IS NULL) + ), + CONSTRAINT chk_identity_link_request_cancellation + CHECK ( + (status = 'CANCELLED' AND cancelled_at IS NOT NULL) + OR + (status <> 'CANCELLED' AND cancelled_at IS NULL) + ) +); + +CREATE UNIQUE INDEX uq_identity_link_request_active_user + ON identity_link_request(primary_user_id) + WHERE status IN ('PENDING_REAUTHENTICATION', 'READY'); + +CREATE INDEX idx_identity_link_request_expiry + ON identity_link_request(expires_at) + WHERE status IN ('PENDING_REAUTHENTICATION', 'READY'); + +CREATE INDEX idx_identity_link_request_binding + ON identity_link_request(target_binding_id) + WHERE target_binding_id IS NOT NULL; + +ALTER TABLE identity_binding + DROP CONSTRAINT identity_binding_provider_code_subject_key; + +CREATE UNIQUE INDEX uq_identity_binding_active_provider_subject + ON identity_binding(provider_code, subject) + WHERE status = 'ACTIVE'; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 39cb8630..9c868c98 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -191,3 +191,25 @@ promotion.sort.pending_unsupported=Pending promotion requests do not support rev error.auth.provider.notFound=Identity provider was not found error.auth.provider.authorityRecoveryMismatch=Provider configuration still points to a different authority error.auth.provider.authorityRecoveryUnavailable=Provider authority cannot be recovered from its current state + +# Identity link and unlink +error.auth.identityLink.intentNotFound=Identity link request was not found +error.auth.identityLink.reauthenticationRequired=Fresh reauthentication is required +error.auth.identityLink.sessionMismatch=Identity link request belongs to another session +error.auth.identityLink.intentExpired=Identity link request has expired +error.auth.identityLink.alreadyConsumed=Identity link request has already been consumed +error.auth.identityLink.activeIntentExists=An identity link request is already active for this account +error.auth.identityLink.accountNotEligible=This account cannot change login methods +error.auth.identityLink.providerUnavailable=The selected identity provider is unavailable +error.auth.identityLink.providerAuthenticationFailed=Identity provider authentication failed or was cancelled +error.auth.identityLink.alreadyLinked=This identity provider is already linked +error.auth.identityLink.identityInUse=This external identity is already linked to an account +error.auth.identityLink.finalLoginMethod=The final usable login method cannot be removed +error.auth.identityLink.invalidOperation=Invalid identity link operation +validation.auth.identityLink.provider.notBlank=Identity provider cannot be blank +validation.auth.identityLink.provider.size=Identity provider must not exceed 64 characters +validation.auth.identityLink.provider.invalid=Identity provider format is invalid +validation.auth.identityLink.binding.positive=Identity binding must be positive +validation.auth.identityLink.binding.required=Identity binding is required +validation.auth.identityLink.username.notBlank=Username cannot be blank +validation.auth.identityLink.password.notBlank=Password cannot be blank diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index eea360fc..079eac2a 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -191,3 +191,25 @@ promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间 error.auth.provider.notFound=未找到身份提供方 error.auth.provider.authorityRecoveryMismatch=身份提供方配置仍指向不同的身份域 error.auth.provider.authorityRecoveryUnavailable=身份提供方当前状态不允许恢复身份域 + +# 身份关联与解绑 +error.auth.identityLink.intentNotFound=未找到身份关联请求 +error.auth.identityLink.reauthenticationRequired=需要重新验证当前账号 +error.auth.identityLink.sessionMismatch=身份关联请求不属于当前会话 +error.auth.identityLink.intentExpired=身份关联请求已过期 +error.auth.identityLink.alreadyConsumed=身份关联请求已被使用 +error.auth.identityLink.activeIntentExists=当前账号已有进行中的身份关联请求 +error.auth.identityLink.accountNotEligible=当前账号不能修改登录方式 +error.auth.identityLink.providerUnavailable=所选身份提供方当前不可用 +error.auth.identityLink.providerAuthenticationFailed=身份提供方认证失败或已取消 +error.auth.identityLink.alreadyLinked=当前账号已关联该身份提供方 +error.auth.identityLink.identityInUse=该外部身份已关联其他账号 +error.auth.identityLink.finalLoginMethod=不能移除最后一种可用登录方式 +error.auth.identityLink.invalidOperation=身份关联操作无效 +validation.auth.identityLink.provider.notBlank=身份提供方不能为空 +validation.auth.identityLink.provider.size=身份提供方不能超过 64 个字符 +validation.auth.identityLink.provider.invalid=身份提供方格式不正确 +validation.auth.identityLink.binding.positive=身份绑定编号必须为正数 +validation.auth.identityLink.binding.required=身份绑定编号不能为空 +validation.auth.identityLink.username.notBlank=用户名不能为空 +validation.auth.identityLink.password.notBlank=密码不能为空 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 new file mode 100644 index 00000000..e6edac50 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkMigrationLoginPostgresIntegrationTest.java @@ -0,0 +1,325 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@SpringBootTest +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@EnabledIfEnvironmentVariable( + named = "IDENTITY_BINDING_V2_POSTGRES_URL", + matches = "jdbc:postgresql:.*") +class IdentityLinkMigrationLoginPostgresIntegrationTest { + + private static final String SCHEMA = + "identity_link_v49_login"; + private static final String USER_ID = + "identity-link-v49-login-user"; + private static final String SUBJECT = + "6554902"; + + @Autowired + private ExternalIdentityLoginService loginService; + + @Autowired + private TrustedProviderRouteResolver routeResolver; + + @Autowired + private ClientRegistrationRepository registrationRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @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"); + prepareV48DatabaseAndUpgrade( + 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", + () -> "false"); + registry.add( + "skillhub.builtin-skills.enabled", + () -> "false"); + } + + @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 V49 login test schema", + exception); + } + } + + @Test + void activeV48BindingStillAuthenticatesThroughUnifiedLogin() + throws Exception { + ClientRegistration registration = + registrationRepository.findByRegistrationId( + "github"); + assertThat(registration).isNotNull(); + ResolvedProviderHandle provider = + routeResolver.resolve(registration); + + IdentityLoginOutcome outcome = loginService.authenticate( + provider, + providerResult(), + IdentityLoginContext.empty()); + + assertThat(outcome) + .isInstanceOf( + IdentityLoginOutcome.Authenticated.class); + IdentityLoginOutcome.Authenticated authenticated = + (IdentityLoginOutcome.Authenticated) outcome; + assertThat(authenticated.principal().userId()) + .isEqualTo(USER_ID); + assertThat(authenticated.accountCreated()).isFalse(); + assertThat(authenticated.bindingCreated()).isFalse(); + assertThat(jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM identity_binding + WHERE user_id = ? + AND provider_code = 'github' + AND subject = ? + AND status = 'ACTIVE' + """, + Long.class, + USER_ID, + SUBJECT)).isEqualTo(1L); + assertThat(jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM identity_link_request + """, + Long.class)).isZero(); + } + + private static void prepareV48DatabaseAndUpgrade( + String url, + String username, + String password) { + dropAndCreateSchema(url, username, password); + Flyway.configure() + .dataSource(url, username, password) + .locations("classpath:db/migration") + .schemas(SCHEMA) + .defaultSchema(SCHEMA) + .createSchemas(true) + .target(MigrationVersion.fromVersion("48")) + .load() + .migrate(); + try (Connection connection = + DriverManager.getConnection( + url, + username, + password); + Statement statement = + connection.createStatement()) { + statement.execute("SET search_path TO " + SCHEMA); + connection.setAutoCommit(false); + statement.executeUpdate(""" + INSERT INTO user_account ( + id, + display_name, + email, + status, + created_at, + updated_at + ) VALUES ( + 'identity-link-v49-login-user', + 'Identity Link V49 Login User', + 'identity-link-v49-login@example.com', + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """); + statement.executeUpdate(""" + INSERT INTO identity_binding ( + user_id, + provider_code, + subject, + login_name, + status, + created_at, + updated_at + ) VALUES ( + 'identity-link-v49-login-user', + 'github', + '6554902', + 'identity-link-v49-login', + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """); + statement.executeUpdate(""" + INSERT INTO identity_binding_subject ( + binding_id, + provider_code, + subject_type, + subject_value, + is_primary, + status, + created_at, + last_seen_at + ) + SELECT + id, + provider_code, + 'github_user_id', + subject, + TRUE, + 'ACTIVE', + created_at, + updated_at + FROM identity_binding + WHERE user_id = + 'identity-link-v49-login-user' + """); + connection.commit(); + } catch (Exception exception) { + throw new IllegalStateException( + "Failed to seed V48 login fixture", + exception); + } + Flyway.configure() + .dataSource(url, username, password) + .locations("classpath:db/migration") + .schemas(SCHEMA) + .defaultSchema(SCHEMA) + .createSchemas(true) + .target(MigrationVersion.fromVersion("49")) + .load() + .migrate(); + } + + private static ProviderAuthenticationResult providerResult() { + return new ProviderAuthenticationResult( + new SubjectCandidate( + "github_user_id", + SUBJECT), + List.of(), + Map.of( + "login", + List.of(new ProviderAttributeValue( + "identity-link-v49-login", + ProviderAttributeTrust.ASSERTED)), + "email", + List.of(new ProviderAttributeValue( + "identity-link-v49-login@example.com", + ProviderAttributeTrust.VERIFIED))), + new ProtocolAuthenticationEvidence( + "oauth2-github", + Instant.now(), + Set.of("oauth2_authorization_code"))); + } + + private static void dropAndCreateSchema( + 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 prepare V49 login test schema", + exception); + } + } + + private static String withCurrentSchema(String url) { + String separator = url.contains("?") ? "&" : "?"; + return url + + separator + + "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; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkMigrationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkMigrationPostgresTest.java new file mode 100644 index 00000000..6bc0bc12 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkMigrationPostgresTest.java @@ -0,0 +1,303 @@ +package com.iflytek.skillhub.auth.identity; + +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 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 IdentityLinkMigrationPostgresTest { + + private static final String SCHEMA = + "identity_link_v49_migration"; + + @Test + void upgradesExistingBindingsAndAllowsRelinkAfterRevocation() + 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) + .target(MigrationVersion.fromVersion("48")) + .load() + .migrate(); + + 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, + email, + status, + created_at, + updated_at + ) VALUES ( + 'identity-link-upgrade-user', + 'Identity Link Upgrade User', + 'identity-link-upgrade@example.com', + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """); + statement.executeUpdate(""" + INSERT INTO identity_provider_state ( + provider_code, + protocol, + authority, + authority_fingerprint, + state + ) VALUES ( + 'github', + 'oauth2-github', + 'https://github.com', + repeat('a', 64), + 'READY' + ) + """); + connection.setAutoCommit(false); + statement.executeUpdate(""" + INSERT INTO identity_binding ( + user_id, + provider_code, + subject, + login_name, + status, + created_at, + updated_at + ) VALUES ( + 'identity-link-upgrade-user', + 'github', + '6554901', + 'identity-link-upgrade', + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """); + statement.executeUpdate(""" + INSERT INTO identity_binding_subject ( + binding_id, + provider_code, + subject_type, + subject_value, + is_primary, + status, + created_at, + last_seen_at + ) + SELECT + id, + provider_code, + 'github_user_id', + subject, + TRUE, + 'ACTIVE', + created_at, + updated_at + FROM identity_binding + WHERE user_id = 'identity-link-upgrade-user' + """); + connection.commit(); + } + + Flyway.configure() + .dataSource(url, username, password) + .locations("classpath:db/migration") + .schemas(SCHEMA) + .defaultSchema(SCHEMA) + .createSchemas(true) + .target(MigrationVersion.fromVersion("49")) + .load() + .migrate(); + + 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 identity_binding + WHERE user_id = 'identity-link-upgrade-user' + AND provider_code = 'github' + AND subject = '6554901' + AND status = 'ACTIVE' + """)).isEqualTo(1L); + assertThat(singleLong( + statement, + """ + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'identity_link_v49_migration' + AND table_name = 'identity_link_request' + """)).isEqualTo(1L); + assertThat(singleLong( + statement, + """ + SELECT COUNT(*) + FROM pg_indexes + WHERE schemaname = 'identity_link_v49_migration' + AND indexname = + 'uq_identity_binding_active_provider_subject' + """)).isEqualTo(1L); + + connection.setAutoCommit(false); + statement.executeUpdate(""" + UPDATE identity_binding_subject + SET + is_primary = FALSE, + status = 'REVOKED', + revoked_at = CURRENT_TIMESTAMP + WHERE provider_code = 'github' + AND subject_value = '6554901' + AND status = 'ACTIVE' + """); + statement.executeUpdate(""" + UPDATE identity_binding + SET + status = 'REVOKED', + revoked_at = CURRENT_TIMESTAMP, + revoked_by = 'identity-link-upgrade-user', + revocation_reason = 'migration test' + WHERE provider_code = 'github' + AND subject = '6554901' + AND status = 'ACTIVE' + """); + statement.executeUpdate(""" + INSERT INTO identity_binding ( + user_id, + provider_code, + subject, + login_name, + status, + created_at, + updated_at + ) VALUES ( + 'identity-link-upgrade-user', + 'github', + '6554901', + 'identity-link-upgrade', + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """); + statement.executeUpdate(""" + INSERT INTO identity_binding_subject ( + binding_id, + provider_code, + subject_type, + subject_value, + is_primary, + status, + created_at, + last_seen_at + ) + SELECT + id, + provider_code, + 'github_user_id', + subject, + TRUE, + 'ACTIVE', + created_at, + updated_at + FROM identity_binding + WHERE user_id = 'identity-link-upgrade-user' + AND provider_code = 'github' + AND subject = '6554901' + AND status = 'ACTIVE' + """); + connection.commit(); + + assertThat(singleLong( + statement, + """ + SELECT COUNT(*) + FROM identity_binding + WHERE provider_code = 'github' + AND subject = '6554901' + AND status = 'ACTIVE' + """)).isEqualTo(1L); + assertThat(singleLong( + statement, + """ + SELECT COUNT(*) + FROM identity_binding + WHERE provider_code = 'github' + AND subject = '6554901' + AND status = 'REVOKED' + """)).isEqualTo(1L); + } + } finally { + dropSchema(url, username, password); + } + } + + 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/identity/IdentityLinkPostgresIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkPostgresIntegrationTest.java new file mode 100644 index 00000000..d648dc66 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkPostgresIntegrationTest.java @@ -0,0 +1,877 @@ +package com.iflytek.skillhub.auth.identity; + +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.Statement; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +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 IdentityLinkPostgresIntegrationTest { + + private static final String SCHEMA = + "identity_link_pr655_integration"; + private static final String PASSWORD = + "IdentityLinkTest!2026"; + private static final IdentityLoginContext CONTEXT = + new IdentityLoginContext( + "req-pr655", + "203.0.113.9", + "Identity Link Integration Test"); + + @Autowired + private IdentityLinkIntentService intentService; + + @Autowired + private ExternalIdentityLinkService externalLinkService; + + @Autowired + private TrustedProviderRouteResolver routeResolver; + + @Autowired + private ClientRegistrationRepository registrationRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PlatformTransactionManager transactionManager; + + @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"); + } + + @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 identity link test schema", + exception); + } + } + + @Test + void linkRequiresFreshReauthenticationAndCannotBeReplayed() { + String userId = "identity-link-user"; + String subject = "6551001"; + seedLocalUser(userId, "identity_link_user"); + IdentityLinkActor actor = actor( + userId, + "nonce-link-user"); + UUID intentId = UUID.randomUUID(); + intentService.createLinkIntent( + actor, + intentId, + "github"); + intentService.reauthenticateLocal( + actor, + intentId, + PASSWORD); + + IdentityLinkOutcome outcome = externalLinkService.link( + actor, + intentId, + githubProvider(), + githubResult(subject)); + + assertThat(outcome) + .isInstanceOf(IdentityLinkOutcome.Linked.class); + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding + WHERE user_id = ? + AND provider_code = 'github' + AND subject = ? + AND status = 'ACTIVE' + """, + userId, + subject)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding_subject + WHERE provider_code = 'github' + AND subject_value = ? + AND status = 'ACTIVE' + AND is_primary = TRUE + """, + subject)).isEqualTo(1L); + assertThatThrownBy(() -> + externalLinkService.link( + actor, + intentId, + githubProvider(), + githubResult(subject))) + .isInstanceOfSatisfying( + IdentityLinkException.class, + exception -> assertThat( + exception.getReasonCode()) + .isEqualTo( + IdentityLinkFailureCode + .ALREADY_CONSUMED)); + assertThat(count( + """ + SELECT COUNT(*) + FROM audit_log + WHERE action = 'IDENTITY_LINK_INTENT_REJECTED' + AND detail_json ->> 'intentId' = ? + AND detail_json ->> 'result' = 'already_consumed' + """, + intentId.toString())).isEqualTo(1L); + } + + @Test + void concurrentConsumptionOfSameIntentCompletesExactlyOnce() + throws Exception { + String userId = "identity-link-intent-race"; + String subject = "6551501"; + seedLocalUser( + userId, + "identity_link_intent_race"); + IdentityLinkActor actor = + actor(userId, "nonce-intent-race"); + UUID intentId = readyLinkIntent( + actor, + PASSWORD); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + try (var executor = + Executors.newVirtualThreadPerTaskExecutor()) { + for (int attempt = 0; attempt < 2; attempt++) { + futures.add(executor.submit(() -> { + start.await(); + return linkOrFailure( + actor, + intentId, + subject); + })); + } + start.countDown(); + List results = List.of( + futures.get(0).get(), + futures.get(1).get()); + + assertThat(results.stream() + .filter(IdentityLinkOutcome.Linked.class::isInstance) + .count()).isEqualTo(1L); + assertThat(results.stream() + .filter(IdentityLinkException.class::isInstance) + .map(IdentityLinkException.class::cast) + .map(IdentityLinkException::getReasonCode)) + .containsExactly( + IdentityLinkFailureCode.ALREADY_CONSUMED); + } + + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding + WHERE user_id = ? + AND provider_code = 'github' + AND subject = ? + AND status = 'ACTIVE' + """, + userId, + subject)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_link_request + WHERE id = ? + AND status = 'COMPLETED' + """, + intentId)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM audit_log + WHERE action = 'IDENTITY_LINK_INTENT_REJECTED' + AND detail_json ->> 'intentId' = ? + AND detail_json ->> 'result' = 'already_consumed' + """, + intentId.toString())).isEqualTo(1L); + } + + @Test + void concurrentSubjectConflictLeavesOneBindingAndReadyLosingIntent() + throws Exception { + String firstUser = "identity-link-race-a"; + String secondUser = "identity-link-race-b"; + String subject = "6552001"; + seedLocalUser(firstUser, "identity_link_race_a"); + seedLocalUser(secondUser, "identity_link_race_b"); + IdentityLinkActor firstActor = + actor(firstUser, "nonce-race-a"); + IdentityLinkActor secondActor = + actor(secondUser, "nonce-race-b"); + UUID firstIntent = readyLinkIntent( + firstActor, + PASSWORD); + UUID secondIntent = readyLinkIntent( + secondActor, + PASSWORD); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + try (var executor = + Executors.newVirtualThreadPerTaskExecutor()) { + futures.add(executor.submit(() -> { + start.await(); + return linkOrFailure( + firstActor, + firstIntent, + subject); + })); + futures.add(executor.submit(() -> { + start.await(); + return linkOrFailure( + secondActor, + secondIntent, + subject); + })); + start.countDown(); + List results = List.of( + futures.get(0).get(), + futures.get(1).get()); + + assertThat(results.stream() + .filter(IdentityLinkOutcome.Linked.class::isInstance) + .count()).isEqualTo(1L); + assertThat(results.stream() + .filter(IdentityLinkException.class::isInstance) + .map(IdentityLinkException.class::cast) + .map(IdentityLinkException::getReasonCode)) + .containsExactly( + IdentityLinkFailureCode.IDENTITY_IN_USE); + } + + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding + WHERE provider_code = 'github' + AND subject = ? + AND status = 'ACTIVE' + """, + subject)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_link_request + WHERE id IN (?, ?) + AND status = 'READY' + """, + firstIntent, + secondIntent)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_link_request + WHERE id IN (?, ?) + AND status = 'COMPLETED' + """, + firstIntent, + secondIntent)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM audit_log + WHERE action = 'IDENTITY_LINK_INTENT_REJECTED' + AND detail_json ->> 'intentId' IN (?, ?) + AND detail_json ->> 'result' = 'identity_in_use' + """, + firstIntent.toString(), + secondIntent.toString())).isEqualTo(1L); + } + + @Test + void unlinkRejectsFinalMethodThenRevokesBindingAndSubjectsAtomically() { + String userId = "identity-unlink-user"; + String subject = "6553001"; + String username = "identity_unlink_user"; + seedLocalUser(userId, username); + IdentityLinkActor actor = actor( + userId, + "nonce-unlink-user"); + UUID linkIntent = readyLinkIntent(actor, PASSWORD); + IdentityLinkOutcome.Linked linked = + (IdentityLinkOutcome.Linked) + externalLinkService.link( + actor, + linkIntent, + githubProvider(), + githubResult(subject)); + jdbcTemplate.update( + "DELETE FROM local_credential WHERE user_id = ?", + userId); + UUID unlinkIntent = UUID.randomUUID(); + intentService.createUnlinkIntent( + actor, + unlinkIntent, + linked.bindingId()); + externalLinkService.reauthenticate( + actor, + unlinkIntent, + githubProvider(), + githubResult(subject)); + + assertThatThrownBy(() -> + intentService.completeUnlink( + actor, + unlinkIntent)) + .isInstanceOfSatisfying( + IdentityLinkException.class, + exception -> assertThat( + exception.getReasonCode()) + .isEqualTo( + IdentityLinkFailureCode + .FINAL_LOGIN_METHOD)); + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding + WHERE id = ? + AND status = 'ACTIVE' + """, + linked.bindingId())).isEqualTo(1L); + + insertLocalCredential(userId, username); + intentService.completeUnlink(actor, unlinkIntent); + + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding + WHERE id = ? + AND status = 'REVOKED' + AND revoked_by = ? + """, + linked.bindingId(), + userId)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding_subject + WHERE binding_id = ? + AND status = 'REVOKED' + AND is_primary = FALSE + """, + linked.bindingId())).isEqualTo(1L); + } + + @Test + void unavailableLegacyProviderCanStillBeSafelyUnlinked() { + String userId = "identity-unlink-legacy-provider"; + String username = "identity_unlink_legacy_provider"; + String providerCode = "legacy-missing"; + String subject = "legacy-missing-655"; + seedLocalUser(userId, username); + long bindingId = insertLegacyBinding( + userId, + providerCode, + subject, + username); + + IdentityLinkBindingView legacyBinding = + intentService.accountState(userId) + .linkedProviders() + .stream() + .filter(binding -> + binding.bindingId() + == bindingId) + .findFirst() + .orElseThrow(); + assertThat(legacyBinding.usable()).isFalse(); + assertThat(legacyBinding.canUnlink()).isTrue(); + + IdentityLinkActor actor = actor( + userId, + "nonce-unlink-legacy-provider"); + UUID intentId = UUID.randomUUID(); + intentService.createUnlinkIntent( + actor, + intentId, + bindingId); + intentService.reauthenticateLocal( + actor, + intentId, + PASSWORD); + intentService.completeUnlink(actor, intentId); + + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding + WHERE id = ? + AND status = 'REVOKED' + """, + bindingId)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_provider_state + WHERE provider_code = ? + """, + providerCode)).isZero(); + } + + @Test + void unlinkedIdentityCanBeLinkedAgainWhileRevocationHistoryIsPreserved() { + String userId = "identity-relink-user"; + String subject = "6554001"; + seedLocalUser(userId, "identity_relink_user"); + IdentityLinkActor actor = actor( + userId, + "nonce-relink-user"); + UUID firstLinkIntent = readyLinkIntent(actor, PASSWORD); + IdentityLinkOutcome.Linked firstLink = + (IdentityLinkOutcome.Linked) + externalLinkService.link( + actor, + firstLinkIntent, + githubProvider(), + githubResult(subject)); + UUID unlinkIntent = UUID.randomUUID(); + intentService.createUnlinkIntent( + actor, + unlinkIntent, + firstLink.bindingId()); + intentService.reauthenticateLocal( + actor, + unlinkIntent, + PASSWORD); + intentService.completeUnlink(actor, unlinkIntent); + + UUID secondLinkIntent = readyLinkIntent(actor, PASSWORD); + IdentityLinkOutcome.Linked secondLink = + (IdentityLinkOutcome.Linked) + externalLinkService.link( + actor, + secondLinkIntent, + githubProvider(), + githubResult(subject)); + + assertThat(secondLink.bindingId()) + .isNotEqualTo(firstLink.bindingId()); + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding + WHERE provider_code = 'github' + AND subject = ? + AND status = 'ACTIVE' + """, + subject)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM identity_binding + WHERE provider_code = 'github' + AND subject = ? + AND status = 'REVOKED' + """, + subject)).isEqualTo(1L); + } + + @Test + void intentExpiryCommitsAndSessionNonceMismatchFailsClosed() { + String userId = "identity-link-expiry-user"; + seedLocalUser( + userId, + "identity_link_expiry_user"); + String rawNonce = "raw-session-nonce-expiry"; + IdentityLinkActor actor = actor(userId, rawNonce); + UUID expiredIntent = UUID.randomUUID(); + intentService.createLinkIntent( + actor, + expiredIntent, + "github"); + String stateHash = jdbcTemplate.queryForObject( + """ + SELECT state_hash + FROM identity_link_request + WHERE id = ? + """, + String.class, + expiredIntent); + assertThat(stateHash) + .hasSize(64) + .isNotEqualTo(rawNonce); + jdbcTemplate.update( + """ + UPDATE identity_link_request + SET expires_at = CURRENT_TIMESTAMP - INTERVAL '1 second' + WHERE id = ? + """, + expiredIntent); + UUID replacementIntent = UUID.randomUUID(); + + intentService.createLinkIntent( + actor, + replacementIntent, + "github"); + + assertThatThrownBy(() -> + intentService.getIntent( + actor, + expiredIntent)) + .isInstanceOfSatisfying( + IdentityLinkException.class, + exception -> assertThat( + exception.getReasonCode()) + .isEqualTo( + IdentityLinkFailureCode + .INTENT_EXPIRED)); + assertThat(jdbcTemplate.queryForObject( + """ + SELECT status + FROM identity_link_request + WHERE id = ? + """, + String.class, + expiredIntent)).isEqualTo("EXPIRED"); + + assertThatThrownBy(() -> + intentService.getIntent( + actor( + userId, + "another-session-nonce"), + replacementIntent)) + .isInstanceOfSatisfying( + IdentityLinkException.class, + exception -> assertThat( + exception.getReasonCode()) + .isEqualTo( + IdentityLinkFailureCode + .SESSION_MISMATCH)); + assertThatThrownBy(() -> + intentService.createLinkIntent( + actor, + UUID.randomUUID(), + "github")) + .isInstanceOfSatisfying( + IdentityLinkException.class, + exception -> assertThat( + exception.getReasonCode()) + .isEqualTo( + IdentityLinkFailureCode + .ACTIVE_INTENT_EXISTS)); + } + + private UUID readyLinkIntent( + IdentityLinkActor actor, + String password) { + UUID intentId = UUID.randomUUID(); + intentService.createLinkIntent( + actor, + intentId, + "github"); + intentService.reauthenticateLocal( + actor, + intentId, + password); + return intentId; + } + + private Object linkOrFailure( + IdentityLinkActor actor, + UUID intentId, + String subject) { + try { + return externalLinkService.link( + actor, + intentId, + githubProvider(), + githubResult(subject)); + } catch (IdentityLinkException exception) { + return exception; + } + } + + private IdentityLinkActor actor( + String userId, + String nonce) { + return new IdentityLinkActor( + userId, + "local", + nonce, + CONTEXT); + } + + private void seedLocalUser( + String userId, + String username) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + id, + display_name, + email, + status, + system_account, + created_at, + updated_at + ) VALUES (?, ?, ?, 'ACTIVE', FALSE, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + userId, + username, + username + "@example.com"); + insertLocalCredential(userId, username); + } + + private void insertLocalCredential( + String userId, + String username) { + jdbcTemplate.update( + """ + INSERT INTO local_credential ( + user_id, + username, + password_hash, + failed_attempts, + created_at, + updated_at + ) VALUES (?, ?, ?, 0, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + userId, + username, + passwordEncoder.encode(PASSWORD)); + } + + private long insertLegacyBinding( + String userId, + String providerCode, + String subject, + String loginName) { + Long bindingId = new TransactionTemplate( + transactionManager).execute(status -> { + jdbcTemplate.update( + """ + INSERT INTO identity_binding ( + user_id, + provider_code, + subject, + login_name, + status, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, 'ACTIVE', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + userId, + providerCode, + subject, + loginName); + Long createdBindingId = + jdbcTemplate.queryForObject( + """ + SELECT id + FROM identity_binding + WHERE user_id = ? + AND provider_code = ? + AND subject = ? + AND status = 'ACTIVE' + """, + Long.class, + userId, + providerCode, + subject); + assertThat(createdBindingId).isNotNull(); + jdbcTemplate.update( + """ + INSERT INTO identity_binding_subject ( + binding_id, + provider_code, + subject_type, + subject_value, + is_primary, + status, + created_at, + last_seen_at + ) VALUES (?, ?, 'legacy_subject', ?, TRUE, + 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + createdBindingId, + providerCode, + subject); + return createdBindingId; + }); + if (bindingId == null) { + throw new IllegalStateException( + "Legacy binding transaction returned no id"); + } + return bindingId; + } + + private ResolvedProviderHandle githubProvider() { + ClientRegistration registration = + registrationRepository.findByRegistrationId( + "github"); + assertThat(registration).isNotNull(); + return routeResolver.resolve(registration); + } + + private ProviderAuthenticationResult githubResult( + String subject) { + return new ProviderAuthenticationResult( + new SubjectCandidate( + "github_user_id", + subject), + List.of(), + Map.of( + "login", + List.of(new ProviderAttributeValue( + "identity-link-test", + ProviderAttributeTrust.ASSERTED)), + "email", + List.of(new ProviderAttributeValue( + subject + "@example.com", + ProviderAttributeTrust.VERIFIED))), + new ProtocolAuthenticationEvidence( + "oauth2-github", + Instant.now(), + Set.of( + "oauth2_authorization_code"))); + } + + private long count(String sql, Object... arguments) { + Long count = jdbcTemplate.queryForObject( + sql, + Long.class, + arguments); + return count == null ? 0L : count; + } + + 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 prepare identity link test schema", + exception); + } + } + + private static String withCurrentSchema(String url) { + String separator = url.contains("?") ? "&" : "?"; + return url + + separator + + "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; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/IdentityLinkControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/IdentityLinkControllerTest.java new file mode 100644 index 00000000..9b3f67e6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/IdentityLinkControllerTest.java @@ -0,0 +1,261 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.entity.IdentityLinkOperation; +import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus; +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.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.dto.IdentityLinkIntentResponse; +import com.iflytek.skillhub.service.IdentityLinkAppService; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; +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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class IdentityLinkControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private IdentityLinkAppService appService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void createLinkIntentRequiresAuthenticatedSessionAndCsrf() + throws Exception { + UUID intentId = UUID.randomUUID(); + given(appService.createLinkIntent( + eq("github"), + any(), + any())) + .willReturn(new IdentityLinkIntentResponse( + intentId, + IdentityLinkOperation.LINK, + IdentityLinkRequestStatus + .PENDING_REAUTHENTICATION, + "github", + null, + Instant.parse( + "2026-07-31T08:10:00Z"))); + + mockMvc.perform( + post("/api/v1/auth/identity-link-intents/link") + .with(authentication(currentAuthentication())) + .with(csrf()) + .session(session()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"providerCode":"github"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id") + .value(intentId.toString())) + .andExpect(jsonPath("$.data.status") + .value("PENDING_REAUTHENTICATION")); + verify(appService).createLinkIntent( + eq("github"), + any(), + any()); + } + + @Test + void createLinkIntentWithoutCsrfIsRejected() throws Exception { + mockMvc.perform( + post("/api/v1/auth/identity-link-intents/link") + .with(authentication(currentAuthentication())) + .session(session()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"providerCode":"github"} + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.reasonCode") + .value("REAUTHENTICATION_REQUIRED")); + + verify(appService, never()).createLinkIntent( + any(), + any(), + any()); + } + + @Test + void createLinkIntentWithoutAuthenticationIsRejected() + throws Exception { + mockMvc.perform( + post("/api/v1/auth/identity-link-intents/link") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"providerCode":"github"} + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.reasonCode") + .value("REAUTHENTICATION_REQUIRED")); + + verify(appService, never()).createLinkIntent( + any(), + any(), + any()); + } + + @Test + void invalidProviderCodeIsRejectedBeforeAppService() + throws Exception { + mockMvc.perform( + post("/api/v1/auth/identity-link-intents/link") + .with(authentication(currentAuthentication())) + .with(csrf()) + .session(session()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"providerCode":"../../other"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.reasonCode") + .value("INVALID_OPERATION")); + + verify(appService, never()).createLinkIntent( + any(), + any(), + any()); + } + + @Test + void missingBindingIdUsesIdentityLinkErrorContract() + throws Exception { + mockMvc.perform( + post("/api/v1/auth/identity-link-intents/unlink") + .with(authentication(currentAuthentication())) + .with(csrf()) + .session(session()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.reasonCode") + .value("INVALID_OPERATION")); + + verify(appService, never()).createUnlinkIntent( + anyLong(), + any(), + any()); + } + + @Test + void localReauthenticationDoesNotCreateANewSession() + throws Exception { + UUID intentId = UUID.randomUUID(); + MockHttpSession session = session(); + String sessionId = session.getId(); + given(appService.reauthenticateLocal( + eq(intentId), + eq("IdentityLinkTest!2026"), + any(), + any())) + .willReturn(new IdentityLinkIntentResponse( + intentId, + IdentityLinkOperation.UNLINK, + IdentityLinkRequestStatus.READY, + "github", + 42L, + Instant.parse( + "2026-07-31T08:10:00Z"))); + + mockMvc.perform(post( + "/api/v1/auth/identity-link-intents/" + + intentId + + "/reauthenticate/local") + .with(authentication(currentAuthentication())) + .with(csrf()) + .session(session) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"password":"IdentityLinkTest!2026"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status") + .value("READY")); + + org.assertj.core.api.Assertions.assertThat( + session.getId()).isEqualTo(sessionId); + } + + @Test + void identityLinkFailureIncludesStableReasonCode() + throws Exception { + UUID intentId = UUID.randomUUID(); + given(appService.completeUnlink( + eq(intentId), + any(), + any())) + .willThrow(new IdentityLinkException( + IdentityLinkFailureCode + .FINAL_LOGIN_METHOD)); + + mockMvc.perform(post( + "/api/v1/auth/identity-link-intents/" + + intentId + + "/unlink") + .with(authentication(currentAuthentication())) + .with(csrf()) + .session(session())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(409)) + .andExpect(jsonPath("$.reasonCode") + .value("FINAL_LOGIN_METHOD")); + } + + private MockHttpSession session() { + MockHttpSession session = new MockHttpSession(); + session.setAttribute( + "platformPrincipal", + principal()); + return session; + } + + private UsernamePasswordAuthenticationToken currentAuthentication() { + return new UsernamePasswordAuthenticationToken( + principal(), + null, + List.of()); + } + + private PlatformPrincipal principal() { + return new PlatformPrincipal( + "usr_1", + "Alice", + "alice@example.com", + null, + "local", + Set.of("USER")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index 2425acde..50082983 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -1,7 +1,6 @@ package com.iflytek.skillhub.controller; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; @@ -116,9 +115,6 @@ class LocalAuthControllerTest { @Test void register_rejectsInvalidEmailFormat() throws Exception { - given(localAuthService.register("bob", "Abcd123!", "not-an-email")) - .willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid")); - mockMvc.perform(post("/api/v1/auth/local/register") .with(csrf()) .header("Accept-Language", "zh-CN") @@ -129,14 +125,11 @@ class LocalAuthControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)); - verify(localAuthService).register("bob", "Abcd123!", "not-an-email"); + verify(localAuthService, never()).register("bob", "Abcd123!", "not-an-email"); } @Test void register_rejectsBlankEmail() throws Exception { - given(localAuthService.register("bob", "Abcd123!", " ")) - .willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank")); - mockMvc.perform(post("/api/v1/auth/local/register") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -146,7 +139,7 @@ class LocalAuthControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)); - verify(localAuthService).register("bob", "Abcd123!", " "); + verify(localAuthService, never()).register("bob", "Abcd123!", " "); } @Test @@ -277,9 +270,6 @@ class LocalAuthControllerTest { @Test void requestPasswordReset_rejectsInvalidEmailFormat() throws Exception { - willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid")) - .given(passwordResetService).requestPasswordReset("alice"); - mockMvc.perform(post("/api/v1/auth/local/password-reset/request") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -289,7 +279,7 @@ class LocalAuthControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)); - verify(passwordResetService).requestPasswordReset("alice"); + verify(passwordResetService, never()).requestPasswordReset("alice"); } @Test @@ -308,9 +298,6 @@ class LocalAuthControllerTest { @Test void confirmPasswordReset_rejectsInvalidEmailFormat() throws Exception { - willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid")) - .given(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!"); - mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -320,7 +307,7 @@ class LocalAuthControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)); - verify(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!"); + verify(passwordResetService, never()).confirmPasswordReset("alice", "123456", "Abcd123!"); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java index aa4a542b..d192a20d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java @@ -187,9 +187,6 @@ class NamespaceBatchMemberControllerTest { @Test void batchAddMembers_emptyArray_returnsError() throws Exception { - // @NotEmpty on BatchMemberRequest.members triggers validation error - // Spring Boot 3.2+ raises HandlerMethodValidationException (500) rather than - // MethodArgumentNotValidException (400) for record-based @RequestBody validation mockMvc.perform(post("/api/v1/namespaces/team-a/members/batch") .with(csrf()) .with(auth("owner-1")) @@ -198,7 +195,7 @@ class NamespaceBatchMemberControllerTest { .content(""" {"members":[]} """)) - .andExpect(status().isInternalServerError()); + .andExpect(status().isBadRequest()); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java index d8c4ac64..58ac25c9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java @@ -6,6 +6,8 @@ import static org.mockito.Mockito.when; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.IdentityLinkErrorResponse; +import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.security.SensitiveLogSanitizer; import jakarta.servlet.http.HttpServletRequest; @@ -40,6 +42,10 @@ class GlobalExceptionHandlerTest { void setUp() { StaticMessageSource messageSource = new StaticMessageSource(); messageSource.addMessage("error.request.timeout", java.util.Locale.getDefault(), "Request timed out"); + messageSource.addMessage( + "error.auth.local.invalidCredentials", + java.util.Locale.getDefault(), + "Invalid username or password"); ApiResponseFactory responseFactory = new ApiResponseFactory( messageSource, Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC) @@ -92,4 +98,33 @@ class GlobalExceptionHandlerTest { assertThatThrownBy(() -> handler.handleSessionInvalidated(ex, request)) .isSameAs(ex); } + + @Test + void handleAuthFailure_shouldUseIdentityLinkReasonCode() { + String path = + "/api/v1/auth/identity-link-intents/test" + + "/reauthenticate/local"; + when(request.getRequestURI()).thenReturn(path); + when(request.getMethod()).thenReturn("POST"); + when(sensitiveLogSanitizer + .sanitizeRequestTarget(request)) + .thenReturn(path); + + ResponseEntity response = + handler.handleAuthFlowException( + new AuthFlowException( + HttpStatus.UNAUTHORIZED, + "error.auth.local.invalidCredentials"), + request); + + assertThat(response.getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()) + .isInstanceOf( + IdentityLinkErrorResponse.class); + IdentityLinkErrorResponse body = + (IdentityLinkErrorResponse) response.getBody(); + assertThat(body.reasonCode()) + .isEqualTo("REAUTHENTICATION_REQUIRED"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java index db8982b7..cf72bfb8 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java @@ -134,4 +134,27 @@ class ApiAccessDeniedHandlerTest { assertThat(body.path("msg").asText()).isEqualTo("Forbidden"); assertThat(response.getContentAsString()).doesNotContain("internal authorization detail"); } + + @Test + void shouldUseStableIdentityLinkReasonForCsrfOrSessionDenial() + throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest( + "POST", + "/api/v1/auth/identity-link-intents/link"); + MockHttpServletResponse response = + new MockHttpServletResponse(); + + handler.handle( + request, + response, + new AccessDeniedException("csrf detail")); + + JsonNode body = objectMapper.readTree( + response.getContentAsByteArray()); + assertThat(response.getStatus()).isEqualTo(403); + assertThat(body.path("reasonCode").asText()) + .isEqualTo("SESSION_MISMATCH"); + assertThat(response.getContentAsString()) + .doesNotContain("csrf detail"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPointTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPointTest.java new file mode 100644 index 00000000..87e27a85 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPointTest.java @@ -0,0 +1,94 @@ +package com.iflytek.skillhub.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Locale; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; + +class ApiAuthenticationEntryPointTest { + + private final ObjectMapper objectMapper = + new ObjectMapper().findAndRegisterModules(); + private ApiAuthenticationEntryPoint entryPoint; + + @BeforeEach + void setUp() { + ResourceBundleMessageSource messageSource = + new ResourceBundleMessageSource(); + messageSource.setBasename("messages"); + messageSource.setDefaultEncoding("UTF-8"); + entryPoint = new ApiAuthenticationEntryPoint( + objectMapper, + new ApiResponseFactory( + messageSource, + Clock.fixed( + Instant.parse( + "2026-07-31T00:00:00Z"), + ZoneOffset.UTC)), + new SensitiveLogSanitizer()); + LocaleContextHolder.setLocale(Locale.ENGLISH); + } + + @AfterEach + void tearDown() { + LocaleContextHolder.resetLocaleContext(); + } + + @Test + void identityLinkRouteUsesStableReauthenticationReason() + throws Exception { + MockHttpServletRequest request = + new MockHttpServletRequest( + "POST", + "/api/v1/auth/identity-link-intents/link"); + MockHttpServletResponse response = + new MockHttpServletResponse(); + + entryPoint.commence( + request, + response, + new AuthenticationCredentialsNotFoundException( + "missing")); + + JsonNode body = objectMapper.readTree( + response.getContentAsByteArray()); + assertThat(response.getStatus()).isEqualTo(401); + assertThat(body.path("reasonCode").asText()) + .isEqualTo("REAUTHENTICATION_REQUIRED"); + } + + @Test + void unrelatedApiRouteKeepsGenericEnvelope() + throws Exception { + MockHttpServletRequest request = + new MockHttpServletRequest( + "GET", + "/api/v1/skills"); + MockHttpServletResponse response = + new MockHttpServletResponse(); + + entryPoint.commence( + request, + response, + new AuthenticationCredentialsNotFoundException( + "missing")); + + JsonNode body = objectMapper.readTree( + response.getContentAsByteArray()); + assertThat(response.getStatus()).isEqualTo(401); + assertThat(body.has("reasonCode")).isFalse(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/IdentityLinkRouteRequestMatcher.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/IdentityLinkRouteRequestMatcher.java new file mode 100644 index 00000000..b23f27ae --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/IdentityLinkRouteRequestMatcher.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.auth.config; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Identifies the HTTP surface whose failures use the stable Identity Link + * error contract. + */ +public final class IdentityLinkRouteRequestMatcher { + + private static final String ACCOUNT_STATE_PATH = + "/api/v1/auth/identity-links"; + private static final String INTENT_PATH_PREFIX = + "/api/v1/auth/identity-link-intents"; + + private IdentityLinkRouteRequestMatcher() { + } + + public static boolean matches(HttpServletRequest request) { + String path = request.getRequestURI(); + return ACCOUNT_STATE_PATH.equals(path) + || (path != null + && (path.equals(INTENT_PATH_PREFIX) + || path.startsWith(INTENT_PATH_PREFIX + "/"))); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 777745b3..786932e8 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -150,7 +150,18 @@ public class SecurityConfig { .invalidSessionStrategy((request, response) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter().write("{\"code\":401,\"msg\":\"Session expired\"}"); + if (IdentityLinkRouteRequestMatcher + .matches(request)) { + response.getWriter().write( + "{\"code\":401," + + "\"msg\":\"Session expired\"," + + "\"reasonCode\":" + + "\"REAUTHENTICATION_REQUIRED\"}"); + } else { + response.getWriter().write( + "{\"code\":401," + + "\"msg\":\"Session expired\"}"); + } }) ) .exceptionHandling(exceptions -> exceptions @@ -169,7 +180,8 @@ public class SecurityConfig { .addFilterBefore( new IdentityProviderRouteReadinessFilter( clientRegistrationRepository, - providerReadinessService), + providerReadinessService, + failureHandler), OAuth2AuthorizationRequestRedirectFilter.class) .addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAfter(apiTokenScopeFilter, ApiTokenAuthenticationFilter.class); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java index edfbe887..aa0dc061 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java @@ -16,11 +16,9 @@ import jakarta.persistence.Id; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; @Entity -@Table(name = "identity_binding", - uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "subject"})) +@Table(name = "identity_binding") public class IdentityBinding { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -123,4 +121,32 @@ public class IdentityBinding { lastSynchronizedAt = synchronizedAt; } } + + public void revoke( + String actorUserId, + String reason, + Instant revokedAt) { + if (status != IdentityBindingStatus.ACTIVE) { + throw new IllegalStateException( + "Only an active identity binding can be revoked"); + } + if (actorUserId == null + || actorUserId.isBlank() + || actorUserId.length() > 128) { + throw new IllegalArgumentException( + "Invalid identity binding revocation actor"); + } + if (reason == null + || reason.isBlank() + || reason.length() > 256) { + throw new IllegalArgumentException( + "Invalid identity binding revocation reason"); + } + status = IdentityBindingStatus.REVOKED; + this.revokedAt = java.util.Objects.requireNonNull( + revokedAt, + "revokedAt"); + revokedBy = actorUserId; + revocationReason = reason; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBindingSubject.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBindingSubject.java index 46c22100..e663fc1d 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBindingSubject.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBindingSubject.java @@ -101,6 +101,15 @@ public class IdentityBindingSubject { } } + public void revoke(Instant revokedAt) { + requireActive(); + primary = false; + status = IdentityBindingSubjectStatus.REVOKED; + this.revokedAt = java.util.Objects.requireNonNull( + revokedAt, + "revokedAt"); + } + private void requireActive() { if (status != IdentityBindingSubjectStatus.ACTIVE) { throw new IllegalStateException( diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkOperation.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkOperation.java new file mode 100644 index 00000000..744e89a7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkOperation.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.auth.entity; + +public enum IdentityLinkOperation { + LINK, + UNLINK +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequest.java new file mode 100644 index 00000000..e82f45ae --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequest.java @@ -0,0 +1,232 @@ +package com.iflytek.skillhub.auth.entity; + +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; + +@Entity +@Table(name = "identity_link_request") +public class IdentityLinkRequest { + + private static final Pattern STATE_HASH_PATTERN = + Pattern.compile("[0-9a-f]{64}"); + + @Id + private UUID id; + + @Column(name = "primary_user_id", nullable = false, length = 128) + private String primaryUserId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private IdentityLinkOperation operation; + + @Column(name = "provider_code", nullable = false, length = 64) + private String providerCode; + + @Column(name = "target_binding_id") + private Long targetBindingId; + + @Column(name = "state_hash", nullable = false, length = 64) + private String stateHash; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private IdentityLinkRequestStatus status; + + @Column(name = "reauthentication_method", length = 96) + private String reauthenticationMethod; + + @Column(name = "reauthenticated_at") + private Instant reauthenticatedAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @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 IdentityLinkRequest() { + } + + public IdentityLinkRequest( + UUID id, + String primaryUserId, + IdentityLinkOperation operation, + String providerCode, + Long targetBindingId, + String stateHash, + Instant expiresAt, + Instant createdAt) { + this.id = Objects.requireNonNull(id, "id"); + this.primaryUserId = requireText(primaryUserId, "primaryUserId", 128); + this.operation = Objects.requireNonNull(operation, "operation"); + this.providerCode = requireText(providerCode, "providerCode", 64); + this.targetBindingId = targetBindingId; + this.stateHash = requireStateHash(stateHash); + this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt"); + if (!expiresAt.isAfter(createdAt)) { + throw new IllegalArgumentException( + "Identity link request expiry must be in the future"); + } + if ((operation == IdentityLinkOperation.LINK && targetBindingId != null) + || (operation == IdentityLinkOperation.UNLINK + && targetBindingId == null)) { + throw new IllegalArgumentException( + "Identity link request target does not match operation"); + } + this.status = IdentityLinkRequestStatus.PENDING_REAUTHENTICATION; + this.updatedAt = createdAt; + } + + public UUID getId() { + return id; + } + + public String getPrimaryUserId() { + return primaryUserId; + } + + public IdentityLinkOperation getOperation() { + return operation; + } + + public String getProviderCode() { + return providerCode; + } + + public Long getTargetBindingId() { + return targetBindingId; + } + + public String getStateHash() { + return stateHash; + } + + public IdentityLinkRequestStatus getStatus() { + return status; + } + + public String getReauthenticationMethod() { + return reauthenticationMethod; + } + + public Instant getReauthenticatedAt() { + return reauthenticatedAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + 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 !now.isBefore(expiresAt); + } + + public void markReauthenticated(String method, Instant now) { + requireStatus(IdentityLinkRequestStatus.PENDING_REAUTHENTICATION); + reauthenticationMethod = requireText( + method, + "reauthenticationMethod", + 96); + reauthenticatedAt = Objects.requireNonNull(now, "now"); + status = IdentityLinkRequestStatus.READY; + updatedAt = now; + } + + public void complete(Instant now) { + requireStatus(IdentityLinkRequestStatus.READY); + completedAt = Objects.requireNonNull(now, "now"); + status = IdentityLinkRequestStatus.COMPLETED; + updatedAt = now; + } + + public void expire(Instant now) { + if (!status.isActive()) { + throw new IllegalStateException( + "Only an active identity link request can expire"); + } + status = IdentityLinkRequestStatus.EXPIRED; + updatedAt = Objects.requireNonNull(now, "now"); + } + + public void cancel(Instant now) { + if (!status.isActive()) { + throw new IllegalStateException( + "Only an active identity link request can be cancelled"); + } + cancelledAt = Objects.requireNonNull(now, "now"); + status = IdentityLinkRequestStatus.CANCELLED; + updatedAt = now; + } + + private void requireStatus(IdentityLinkRequestStatus required) { + if (status != required) { + throw new IllegalStateException( + "Identity link request is not in state " + required); + } + } + + private static String requireStateHash(String value) { + if (value == null || !STATE_HASH_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException( + "Invalid identity link state hash"); + } + return value; + } + + private static String requireText( + String value, + String fieldName, + int maximumLength) { + if (value == null + || value.isBlank() + || value.length() > maximumLength) { + throw new IllegalArgumentException( + "Invalid identity link " + fieldName); + } + return value; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequestStatus.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequestStatus.java new file mode 100644 index 00000000..4dd0e99b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequestStatus.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.entity; + +public enum IdentityLinkRequestStatus { + PENDING_REAUTHENTICATION, + READY, + COMPLETED, + EXPIRED, + CANCELLED; + + public boolean isActive() { + return this == PENDING_REAUTHENTICATION || this == READY; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLinkService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLinkService.java new file mode 100644 index 00000000..941ca19e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLinkService.java @@ -0,0 +1,132 @@ +package com.iflytek.skillhub.auth.identity; + +import java.sql.SQLException; +import java.util.Objects; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +/** + * Core-owned Identity Link facade. Protocol adapters can only provide verified + * external facts; trusted provider identity and assertion construction remain + * inside the identity core. + */ +@Service +class DefaultExternalIdentityLinkService + implements ExternalIdentityLinkService { + + private static final Logger log = LoggerFactory.getLogger( + DefaultExternalIdentityLinkService.class); + + private final TrustedProviderDescriptorSource descriptorSource; + private final ProviderAuthorityLockService authorityLockService; + private final IdentityAssertionFactory assertionFactory; + private final IdentityLinkTransaction transaction; + + DefaultExternalIdentityLinkService( + TrustedProviderDescriptorSource descriptorSource, + ProviderAuthorityLockService authorityLockService, + IdentityAssertionFactory assertionFactory, + IdentityLinkTransaction transaction) { + this.descriptorSource = descriptorSource; + this.authorityLockService = authorityLockService; + this.assertionFactory = assertionFactory; + this.transaction = transaction; + } + + @Override + public IdentityLinkOutcome reauthenticate( + IdentityLinkActor actor, + UUID intentId, + ResolvedProviderHandle provider, + ProviderAuthenticationResult result) { + PreparedAssertion prepared = prepare(provider, result); + return new IdentityLinkOutcome.Reauthenticated( + transaction.markExternalReauthenticated( + actor, + intentId, + prepared.assertion(), + prepared.descriptor())); + } + + @Override + public IdentityLinkOutcome link( + IdentityLinkActor actor, + UUID intentId, + ResolvedProviderHandle provider, + ProviderAuthenticationResult result) { + PreparedAssertion prepared = prepare(provider, result); + try { + IdentityLinkTransaction.LinkedBinding linked = + transaction.link( + actor, + intentId, + prepared.assertion(), + prepared.descriptor()); + return new IdentityLinkOutcome.Linked( + linked.principal(), + linked.bindingId()); + } catch (DataIntegrityViolationException exception) { + if (isUniqueConstraintViolation(exception)) { + recordIdentityInUseDenial(actor, intentId); + throw new IdentityLinkException( + IdentityLinkFailureCode.IDENTITY_IN_USE, + exception); + } + throw exception; + } + } + + private void recordIdentityInUseDenial( + IdentityLinkActor actor, + UUID intentId) { + try { + transaction.recordRejectedAfterRollback( + actor, + intentId, + IdentityLinkFailureCode.IDENTITY_IN_USE); + } catch (RuntimeException auditFailure) { + log.error( + "Identity Link denial audit failed for intent '{}' and reason '{}'", + intentId, + IdentityLinkFailureCode.IDENTITY_IN_USE, + auditFailure); + } + } + + private PreparedAssertion prepare( + ResolvedProviderHandle provider, + ProviderAuthenticationResult result) { + Objects.requireNonNull(provider, "provider"); + Objects.requireNonNull(result, "result"); + ProviderDescriptor descriptor = + descriptorSource.require(provider); + authorityLockService.requirePinnedAuthority(descriptor); + return new PreparedAssertion( + descriptor, + assertionFactory.create(descriptor, result)); + } + + 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; + } + + private record PreparedAssertion( + ProviderDescriptor descriptor, + IdentityAssertion assertion) { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityLinkService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityLinkService.java new file mode 100644 index 00000000..781d3963 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityLinkService.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.UUID; + +/** + * Unified facade for external fresh reauthentication and explicit Identity + * Link. Protocol adapters provide verified external facts only. + */ +public interface ExternalIdentityLinkService { + + IdentityLinkOutcome reauthenticate( + IdentityLinkActor actor, + UUID intentId, + ResolvedProviderHandle provider, + ProviderAuthenticationResult result); + + IdentityLinkOutcome link( + IdentityLinkActor actor, + UUID intentId, + ResolvedProviderHandle provider, + ProviderAuthenticationResult result); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkAccountState.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkAccountState.java new file mode 100644 index 00000000..39a613a2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkAccountState.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.List; + +public record IdentityLinkAccountState( + boolean localPasswordEnabled, + List linkedProviders, + List availableProviders +) { + public IdentityLinkAccountState { + linkedProviders = List.copyOf(linkedProviders); + availableProviders = List.copyOf(availableProviders); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkActor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkActor.java new file mode 100644 index 00000000..6eb3d140 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkActor.java @@ -0,0 +1,73 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +/** + * Server-owned account and session proof used by Identity Link workflows. + * + *

The raw session nonce is intentionally omitted from {@link #toString()}. + */ +public final class IdentityLinkActor { + + private final String userId; + private final String authenticationProvider; + private final String sessionNonce; + private final IdentityLoginContext auditContext; + + public IdentityLinkActor( + String userId, + String authenticationProvider, + String sessionNonce, + IdentityLoginContext auditContext) { + this.userId = requireText(userId, "userId", 128); + this.authenticationProvider = requireText( + authenticationProvider, + "authenticationProvider", + 64); + this.sessionNonce = requireText( + sessionNonce, + "sessionNonce", + 256); + this.auditContext = Objects.requireNonNull( + auditContext, + "auditContext"); + } + + public String userId() { + return userId; + } + + String authenticationProvider() { + return authenticationProvider; + } + + String sessionNonce() { + return sessionNonce; + } + + public IdentityLoginContext auditContext() { + return auditContext; + } + + @Override + public String toString() { + return "IdentityLinkActor[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 identity link actor " + fieldName); + } + return value; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBindingView.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBindingView.java new file mode 100644 index 00000000..776e6eaa --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBindingView.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType; +import java.util.Set; + +public record IdentityLinkBindingView( + long bindingId, + String providerCode, + String displayName, + Set methodTypes, + boolean usable, + boolean canUnlink +) { + public IdentityLinkBindingView { + if (bindingId <= 0) { + throw new IllegalArgumentException( + "Identity binding id must be positive"); + } + if (providerCode == null || providerCode.isBlank()) { + throw new IllegalArgumentException( + "Identity provider code is required"); + } + if (displayName == null || displayName.isBlank()) { + throw new IllegalArgumentException( + "Identity provider display name is required"); + } + methodTypes = Set.copyOf(methodTypes); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserFlow.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserFlow.java new file mode 100644 index 00000000..50697112 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserFlow.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.UUID; + +public record IdentityLinkBrowserFlow( + UUID intentId, + IdentityLinkBrowserPhase phase, + IdentityLinkActor actor +) { +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserPhase.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserPhase.java new file mode 100644 index 00000000..852da8b3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkBrowserPhase.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.auth.identity; + +public enum IdentityLinkBrowserPhase { + REAUTHENTICATE, + LINK +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkException.java new file mode 100644 index 00000000..00b3affb --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkException.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; + +public final class IdentityLinkException extends AuthFlowException { + + private final IdentityLinkFailureCode reasonCode; + + public IdentityLinkException(IdentityLinkFailureCode reasonCode) { + this(reasonCode, null); + } + + public IdentityLinkException( + IdentityLinkFailureCode reasonCode, + Throwable cause) { + super(reasonCode.status(), reasonCode.messageCode()); + this.reasonCode = reasonCode; + if (cause != null) { + initCause(cause); + } + } + + public IdentityLinkFailureCode getReasonCode() { + return reasonCode; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkFailureCode.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkFailureCode.java new file mode 100644 index 00000000..15e26323 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkFailureCode.java @@ -0,0 +1,63 @@ +package com.iflytek.skillhub.auth.identity; + +import org.springframework.http.HttpStatus; + +public enum IdentityLinkFailureCode { + INTENT_NOT_FOUND( + HttpStatus.NOT_FOUND, + "error.auth.identityLink.intentNotFound"), + REAUTHENTICATION_REQUIRED( + HttpStatus.UNAUTHORIZED, + "error.auth.identityLink.reauthenticationRequired"), + SESSION_MISMATCH( + HttpStatus.FORBIDDEN, + "error.auth.identityLink.sessionMismatch"), + INTENT_EXPIRED( + HttpStatus.GONE, + "error.auth.identityLink.intentExpired"), + ALREADY_CONSUMED( + HttpStatus.CONFLICT, + "error.auth.identityLink.alreadyConsumed"), + ACTIVE_INTENT_EXISTS( + HttpStatus.CONFLICT, + "error.auth.identityLink.activeIntentExists"), + ACCOUNT_NOT_ELIGIBLE( + HttpStatus.CONFLICT, + "error.auth.identityLink.accountNotEligible"), + PROVIDER_UNAVAILABLE( + HttpStatus.SERVICE_UNAVAILABLE, + "error.auth.identityLink.providerUnavailable"), + PROVIDER_AUTHENTICATION_FAILED( + HttpStatus.UNAUTHORIZED, + "error.auth.identityLink.providerAuthenticationFailed"), + ALREADY_LINKED( + HttpStatus.CONFLICT, + "error.auth.identityLink.alreadyLinked"), + IDENTITY_IN_USE( + HttpStatus.CONFLICT, + "error.auth.identityLink.identityInUse"), + FINAL_LOGIN_METHOD( + HttpStatus.CONFLICT, + "error.auth.identityLink.finalLoginMethod"), + INVALID_OPERATION( + HttpStatus.BAD_REQUEST, + "error.auth.identityLink.invalidOperation"); + + private final HttpStatus status; + private final String messageCode; + + IdentityLinkFailureCode( + 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/identity/IdentityLinkIntent.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntent.java new file mode 100644 index 00000000..f4aee583 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntent.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.entity.IdentityLinkOperation; +import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public record IdentityLinkIntent( + UUID id, + IdentityLinkOperation operation, + IdentityLinkRequestStatus status, + String providerCode, + Long targetBindingId, + Instant expiresAt +) { + public IdentityLinkIntent { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(providerCode, "providerCode"); + Objects.requireNonNull(expiresAt, "expiresAt"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentService.java new file mode 100644 index 00000000..9a3acffe --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentService.java @@ -0,0 +1,152 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import java.sql.SQLException; +import java.util.Objects; +import java.util.UUID; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +/** + * Public workflow facade for creating, inspecting, reauthenticating, and + * consuming Identity Link intents. + * + *

Raw session nonces are supplied by the HTTP session boundary and are + * never persisted by this service. + */ +@Service +public class IdentityLinkIntentService { + + private final IdentityLinkTransaction transaction; + private final LocalAuthService localAuthService; + + IdentityLinkIntentService( + IdentityLinkTransaction transaction, + LocalAuthService localAuthService) { + this.transaction = transaction; + this.localAuthService = localAuthService; + } + + public IdentityLinkIntent createLinkIntent( + IdentityLinkActor actor, + UUID intentId, + String providerCode) { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(intentId, "intentId"); + try { + return transaction.createLinkIntent( + actor, + intentId, + providerCode); + } catch (DataIntegrityViolationException exception) { + if (isUniqueConstraintViolation(exception)) { + throw new IdentityLinkException( + IdentityLinkFailureCode.ACTIVE_INTENT_EXISTS, + exception); + } + throw exception; + } + } + + public IdentityLinkIntent createUnlinkIntent( + IdentityLinkActor actor, + UUID intentId, + long bindingId) { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(intentId, "intentId"); + try { + return transaction.createUnlinkIntent( + actor, + intentId, + bindingId); + } catch (DataIntegrityViolationException exception) { + if (isUniqueConstraintViolation(exception)) { + throw new IdentityLinkException( + IdentityLinkFailureCode.ACTIVE_INTENT_EXISTS, + exception); + } + throw exception; + } + } + + public IdentityLinkIntent getIntent( + IdentityLinkActor actor, + UUID intentId) { + return transaction.getIntent(actor, intentId); + } + + public IdentityLinkIntent cancel( + IdentityLinkActor actor, + UUID intentId) { + return transaction.cancel(actor, intentId); + } + + public IdentityLinkIntent reauthenticateLocal( + IdentityLinkActor actor, + UUID intentId, + String password) { + IdentityLinkIntent intent = transaction.getIntent( + actor, + intentId); + if (intent.status() + != IdentityLinkRequestStatus.PENDING_REAUTHENTICATION) { + throw new IdentityLinkException( + IdentityLinkFailureCode.ALREADY_CONSUMED); + } + localAuthService.reauthenticate( + actor.userId(), + password); + return transaction.markLocalReauthenticated( + actor, + intentId); + } + + public IdentityLinkIntent prepareExternalReauthentication( + IdentityLinkActor actor, + UUID intentId, + String providerCode, + IdentityProviderLoginMethodType methodType) { + return transaction.prepareExternalReauthentication( + actor, + intentId, + providerCode, + methodType); + } + + public IdentityLinkIntent prepareExternalLink( + IdentityLinkActor actor, + UUID intentId, + IdentityProviderLoginMethodType methodType) { + return transaction.prepareExternalLink( + actor, + intentId, + methodType); + } + + public IdentityLinkIntent completeUnlink( + IdentityLinkActor actor, + UUID intentId) { + return transaction.completeUnlink(actor, intentId); + } + + public IdentityLinkAccountState accountState(String userId) { + return transaction.accountState(userId); + } + + 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/identity/IdentityLinkOutcome.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkOutcome.java new file mode 100644 index 00000000..b58311c8 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkOutcome.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import java.util.Objects; + +public sealed interface IdentityLinkOutcome { + + record Reauthenticated( + PlatformPrincipal principal + ) implements IdentityLinkOutcome { + public Reauthenticated { + Objects.requireNonNull(principal, "principal"); + } + } + + record Linked( + PlatformPrincipal principal, + long bindingId + ) implements IdentityLinkOutcome { + public Linked { + Objects.requireNonNull(principal, "principal"); + if (bindingId <= 0) { + throw new IllegalArgumentException( + "Identity binding id must be positive"); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkProviderView.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkProviderView.java new file mode 100644 index 00000000..aeaf7a95 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkProviderView.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Set; + +public record IdentityLinkProviderView( + String providerCode, + String displayName, + Set methodTypes +) { + public IdentityLinkProviderView { + if (providerCode == null || providerCode.isBlank()) { + throw new IllegalArgumentException( + "Identity provider code is required"); + } + if (displayName == null || displayName.isBlank()) { + throw new IllegalArgumentException( + "Identity provider display name is required"); + } + methodTypes = Set.copyOf(methodTypes); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java new file mode 100644 index 00000000..d276449a --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManager.java @@ -0,0 +1,286 @@ +package com.iflytek.skillhub.auth.identity; + +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 the raw, high-entropy session state used by Identity Link workflows. + * + *

Only a SHA-256 digest is stored outside the session. Raw nonces and OAuth + * state values are never returned by API DTOs or written to the database. + */ +@Component +public class IdentityLinkSessionManager { + + private static final String NONCE_ATTRIBUTE_PREFIX = + "skillhub.identityLink.nonce."; + private static final String PENDING_BROWSER_FLOW_ATTRIBUTE = + "skillhub.identityLink.browser.pending"; + private static final String ACTIVE_BROWSER_FLOW_ATTRIBUTE = + "skillhub.identityLink.browser.active"; + private static final Duration BROWSER_FLOW_TTL = + Duration.ofMinutes(5); + + private final SecureRandom secureRandom; + private final IdentityLinkStateHasher stateHasher; + private final Clock clock; + + @Autowired + public IdentityLinkSessionManager( + IdentityLinkStateHasher stateHasher, + Clock clock) { + this( + new SecureRandom(), + stateHasher, + clock); + } + + IdentityLinkSessionManager( + SecureRandom secureRandom, + IdentityLinkStateHasher stateHasher, + Clock clock) { + this.secureRandom = secureRandom; + this.stateHasher = stateHasher; + this.clock = clock; + } + + public IdentityLinkActor start( + HttpSession session, + UUID intentId, + IdentityLoginContext context) { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(intentId, "intentId"); + byte[] nonceBytes = new byte[32]; + secureRandom.nextBytes(nonceBytes); + String nonce = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(nonceBytes); + session.setAttribute( + nonceAttribute(intentId), + nonce); + return actor(session, intentId, context); + } + + public IdentityLinkActor actor( + HttpSession session, + UUID intentId, + IdentityLoginContext context) { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(intentId, "intentId"); + Object principalValue = + session.getAttribute("platformPrincipal"); + if (!(principalValue instanceof PlatformPrincipal principal)) { + throw new IdentityLinkException( + IdentityLinkFailureCode.SESSION_MISMATCH); + } + Object nonceValue = session.getAttribute( + nonceAttribute(intentId)); + if (!(nonceValue instanceof String nonce) + || nonce.isBlank()) { + throw new IdentityLinkException( + IdentityLinkFailureCode.SESSION_MISMATCH); + } + String authenticationProvider = + principal.oauthProvider() == null + || principal.oauthProvider().isBlank() + ? "session" + : principal.oauthProvider(); + return new IdentityLinkActor( + principal.userId(), + authenticationProvider, + nonce, + context); + } + + public void remove(HttpSession session, UUID intentId) { + if (session == null || intentId == null) { + return; + } + session.removeAttribute(nonceAttribute(intentId)); + clearBrowserFlowForIntent(session, intentId); + } + + public void prepareBrowserFlow( + HttpSession session, + UUID intentId, + IdentityLinkBrowserPhase phase, + String providerCode, + IdentityLoginContext context) { + actor(session, intentId, context); + PendingBrowserFlow pending = new PendingBrowserFlow( + intentId, + Objects.requireNonNull(phase, "phase"), + requireProviderCode(providerCode), + now().plus(BROWSER_FLOW_TTL)); + session.setAttribute( + PENDING_BROWSER_FLOW_ATTRIBUTE, + pending); + session.removeAttribute(ACTIVE_BROWSER_FLOW_ATTRIBUTE); + } + + /** + * Binds a prepared link flow to the OAuth authorization request generated + * by Spring Security. The raw OAuth state remains in Spring Security's + * authorization request repository; only its digest is copied here. + */ + public void activateBrowserFlow( + HttpSession session, + String providerCode, + String oauthState) { + if (session == null) { + return; + } + Object value = session.getAttribute( + PENDING_BROWSER_FLOW_ATTRIBUTE); + session.removeAttribute(PENDING_BROWSER_FLOW_ATTRIBUTE); + if (!(value instanceof PendingBrowserFlow pending) + || pending.expiresAt().isBefore(now()) + || !pending.providerCode().equals(providerCode) + || oauthState == null + || oauthState.isBlank()) { + return; + } + session.setAttribute( + ACTIVE_BROWSER_FLOW_ATTRIBUTE, + new ActiveBrowserFlow( + pending.intentId(), + pending.phase(), + pending.providerCode(), + stateHasher.hash(oauthState), + 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"); + if (active.expiresAt().isBefore(now()) + || !active.providerCode().equals(providerCode) + || !stateHasher.matches( + callbackState, + active.oauthStateHash())) { + throw new IdentityLinkException( + IdentityLinkFailureCode.SESSION_MISMATCH); + } + return Optional.of(new IdentityLinkBrowserFlow( + active.intentId(), + active.phase(), + actor( + session, + active.intentId(), + context))); + } + + public void clearBrowserFlow(HttpSession session) { + if (session == null) { + return; + } + session.removeAttribute(PENDING_BROWSER_FLOW_ATTRIBUTE); + session.removeAttribute(ACTIVE_BROWSER_FLOW_ATTRIBUTE); + } + + /** + * Clears a failed browser flow while retaining the session-bound intent + * nonce so the account-security UI can safely resume or cancel it. + */ + 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(flow.intentId()); + } + if (pending instanceof PendingBrowserFlow flow) { + return Optional.of(flow.intentId()); + } + return Optional.empty(); + } + + private void clearBrowserFlowForIntent( + HttpSession session, + UUID intentId) { + Object pending = session.getAttribute( + PENDING_BROWSER_FLOW_ATTRIBUTE); + if (pending instanceof PendingBrowserFlow flow + && flow.intentId().equals(intentId)) { + session.removeAttribute( + PENDING_BROWSER_FLOW_ATTRIBUTE); + } + Object active = session.getAttribute( + ACTIVE_BROWSER_FLOW_ATTRIBUTE); + if (active instanceof ActiveBrowserFlow flow + && flow.intentId().equals(intentId)) { + session.removeAttribute( + ACTIVE_BROWSER_FLOW_ATTRIBUTE); + } + } + + private String nonceAttribute(UUID intentId) { + return NONCE_ATTRIBUTE_PREFIX + intentId; + } + + private String requireProviderCode(String providerCode) { + if (providerCode == null + || providerCode.isBlank() + || providerCode.length() > 64) { + throw new IdentityLinkException( + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); + } + return providerCode; + } + + private Instant now() { + return Instant.now(clock); + } + + private record PendingBrowserFlow( + UUID intentId, + IdentityLinkBrowserPhase phase, + String providerCode, + Instant expiresAt + ) implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + } + + private record ActiveBrowserFlow( + UUID intentId, + IdentityLinkBrowserPhase phase, + String providerCode, + String oauthStateHash, + Instant expiresAt + ) implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkStateHasher.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkStateHasher.java new file mode 100644 index 00000000..4e769b52 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkStateHasher.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.auth.identity; + +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 IdentityLinkStateHasher { + + String hash(String rawState) { + if (rawState == null || rawState.isBlank()) { + throw new IllegalArgumentException( + "Identity link state must not be blank"); + } + return HexFormat.of().formatHex( + sha256(rawState.getBytes(StandardCharsets.UTF_8))); + } + + boolean matches(String rawState, String expectedHash) { + if (rawState == null || expectedHash == null) { + return false; + } + byte[] actual = sha256(rawState.getBytes(StandardCharsets.UTF_8)); + byte[] expected; + try { + expected = HexFormat.of().parseHex(expectedHash); + } catch (IllegalArgumentException exception) { + return false; + } + return MessageDigest.isEqual(actual, expected); + } + + private byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException( + "SHA-256 is unavailable", + exception); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java new file mode 100644 index 00000000..32e74a23 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLinkTransaction.java @@ -0,0 +1,917 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.entity.IdentityBindingStatus; +import com.iflytek.skillhub.auth.entity.IdentityBindingSubject; +import com.iflytek.skillhub.auth.entity.IdentityBindingSubjectStatus; +import com.iflytek.skillhub.auth.entity.IdentityLinkOperation; +import com.iflytek.skillhub.auth.entity.IdentityLinkRequest; +import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.IdentityBindingSubjectRepository; +import com.iflytek.skillhub.auth.repository.IdentityLinkRequestRepository; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +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.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * Short PostgreSQL transaction for Identity Link request state, Binding V2 + * creation, and safe revocation. Protocol I/O and credential verification run + * before these methods are invoked. + */ +@Service +class IdentityLinkTransaction { + + static final Duration INTENT_TTL = Duration.ofMinutes(10); + private static final String USER_UNLINK_REASON = + "User removed linked login method"; + + private final IdentityLinkRequestRepository requestRepository; + private final IdentityBindingRepository bindingRepository; + private final IdentityBindingSubjectRepository subjectRepository; + private final LocalCredentialRepository credentialRepository; + private final UserAccountRepository userRepository; + private final IdentityProviderRegistry providerRegistry; + private final IdentityLinkStateHasher stateHasher; + private final AccountLoginGuard accountLoginGuard; + private final PlatformPrincipalFactory principalFactory; + private final AuditLogService auditLogService; + private final Clock clock; + + IdentityLinkTransaction( + IdentityLinkRequestRepository requestRepository, + IdentityBindingRepository bindingRepository, + IdentityBindingSubjectRepository subjectRepository, + LocalCredentialRepository credentialRepository, + UserAccountRepository userRepository, + IdentityProviderRegistry providerRegistry, + IdentityLinkStateHasher stateHasher, + AccountLoginGuard accountLoginGuard, + PlatformPrincipalFactory principalFactory, + AuditLogService auditLogService, + Clock clock) { + this.requestRepository = requestRepository; + this.bindingRepository = bindingRepository; + this.subjectRepository = subjectRepository; + this.credentialRepository = credentialRepository; + this.userRepository = userRepository; + this.providerRegistry = providerRegistry; + this.stateHasher = stateHasher; + this.accountLoginGuard = accountLoginGuard; + this.principalFactory = principalFactory; + this.auditLogService = auditLogService; + this.clock = clock; + } + + @Transactional(noRollbackFor = IdentityLinkException.class) + public IdentityLinkIntent createLinkIntent( + IdentityLinkActor actor, + UUID intentId, + String providerCode) { + Instant now = now(); + requireEligibleAccount(actor.userId()); + requireReadyLinkProvider(providerCode); + boolean alreadyLinked = bindingRepository + .findByUserIdAndStatus( + actor.userId(), + IdentityBindingStatus.ACTIVE) + .stream() + .anyMatch(binding -> binding.getProviderCode() + .equals(providerCode)); + if (alreadyLinked) { + throw failure(IdentityLinkFailureCode.ALREADY_LINKED); + } + requireNoActiveRequest(actor, now); + + IdentityLinkRequest request = new IdentityLinkRequest( + intentId, + actor.userId(), + IdentityLinkOperation.LINK, + providerCode, + null, + stateHasher.hash(actor.sessionNonce()), + now.plus(INTENT_TTL), + now); + requestRepository.saveAndFlush(request); + recordAudit( + actor, + "IDENTITY_LINK_INTENT_CREATED", + request, + "pending_reauthentication"); + return toIntent(request); + } + + @Transactional(noRollbackFor = IdentityLinkException.class) + public IdentityLinkIntent createUnlinkIntent( + IdentityLinkActor actor, + UUID intentId, + long bindingId) { + Instant now = now(); + requireEligibleAccount(actor.userId()); + requireNoActiveRequest(actor, now); + IdentityBinding binding = bindingRepository + .findByIdAndStatusForUpdate( + bindingId, + IdentityBindingStatus.ACTIVE) + .filter(candidate -> candidate.getUserId() + .equals(actor.userId())) + .orElseThrow(() -> + failure(IdentityLinkFailureCode.INTENT_NOT_FOUND)); + + IdentityLinkRequest request = new IdentityLinkRequest( + intentId, + actor.userId(), + IdentityLinkOperation.UNLINK, + binding.getProviderCode(), + binding.getId(), + stateHasher.hash(actor.sessionNonce()), + now.plus(INTENT_TTL), + now); + requestRepository.saveAndFlush(request); + recordAudit( + actor, + "IDENTITY_UNLINK_INTENT_CREATED", + request, + "pending_reauthentication"); + return toIntent(request); + } + + @Transactional(noRollbackFor = IdentityLinkException.class) + public IdentityLinkIntent getIntent( + IdentityLinkActor actor, + UUID intentId) { + IdentityLinkRequest request = requireRequest( + actor, + intentId); + requireActive(request, actor); + return toIntent(request); + } + + @Transactional(noRollbackFor = IdentityLinkException.class) + public IdentityLinkIntent cancel( + IdentityLinkActor actor, + UUID intentId) { + IdentityLinkRequest request = requireRequest( + actor, + intentId); + requireActive(request, actor); + request.cancel(now()); + recordAudit( + actor, + "IDENTITY_LINK_INTENT_CANCELLED", + request, + "cancelled"); + return toIntent(request); + } + + @Transactional(noRollbackFor = IdentityLinkException.class) + public IdentityLinkIntent markLocalReauthenticated( + IdentityLinkActor actor, + UUID intentId) { + IdentityLinkRequest request = requireRequest( + actor, + intentId); + requirePendingReauthentication(request, actor); + requireEligibleAccount(actor.userId()); + request.markReauthenticated("local-password", now()); + recordAudit( + actor, + "IDENTITY_LINK_ACCOUNT_REAUTHENTICATED", + request, + "local-password"); + return toIntent(request); + } + + @Transactional(noRollbackFor = IdentityLinkException.class) + public IdentityLinkIntent prepareExternalReauthentication( + IdentityLinkActor actor, + UUID intentId, + String providerCode, + IdentityProviderLoginMethodType methodType) { + IdentityLinkRequest request = requireRequest( + actor, + intentId); + requirePendingReauthentication(request, actor); + requireEligibleAccount(actor.userId()); + requireProviderCapability( + actor, + request, + providerCode, + methodType); + boolean linked = bindingRepository + .findByUserIdAndStatus( + actor.userId(), + IdentityBindingStatus.ACTIVE) + .stream() + .anyMatch(binding -> binding.getProviderCode() + .equals(providerCode)); + if (!linked) { + throw reject( + actor, + request, + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); + } + return toIntent(request); + } + + @Transactional(noRollbackFor = IdentityLinkException.class) + public IdentityLinkIntent prepareExternalLink( + IdentityLinkActor actor, + UUID intentId, + IdentityProviderLoginMethodType methodType) { + IdentityLinkRequest request = requireRequest( + actor, + intentId); + requireReady( + request, + actor, + IdentityLinkOperation.LINK); + requireEligibleAccount(actor.userId()); + requireProviderCapability( + actor, + request, + request.getProviderCode(), + methodType); + boolean alreadyLinked = bindingRepository + .findByUserIdAndStatus( + actor.userId(), + IdentityBindingStatus.ACTIVE) + .stream() + .anyMatch(binding -> binding.getProviderCode() + .equals(request.getProviderCode())); + if (alreadyLinked) { + throw reject( + actor, + request, + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); + } + return toIntent(request); + } + + @Transactional(noRollbackFor = IdentityLinkException.class) + public PlatformPrincipal markExternalReauthenticated( + IdentityLinkActor actor, + UUID intentId, + IdentityAssertion assertion, + ProviderDescriptor descriptor) { + IdentityLinkRequest request = requireRequest( + actor, + intentId); + requirePendingReauthentication(request, actor); + UserAccount user = requireEligibleAccount(actor.userId()); + IdentityBinding binding = resolveAuthenticatedBinding( + assertion, + descriptor); + if (!binding.getUserId().equals(actor.userId())) { + throw reject( + actor, + request, + IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE); + } + request.markReauthenticated( + "provider:" + assertion.provider().providerCode(), + now()); + recordAudit( + actor, + "IDENTITY_LINK_ACCOUNT_REAUTHENTICATED", + request, + assertion.provider().providerCode()); + return principalFactory.create( + user, + actor.authenticationProvider()); + } + + @Transactional(noRollbackFor = IdentityLinkException.class) + public LinkedBinding link( + IdentityLinkActor actor, + UUID intentId, + IdentityAssertion assertion, + ProviderDescriptor descriptor) { + IdentityLinkRequest request = requireRequest( + actor, + intentId); + requireReady(request, actor, IdentityLinkOperation.LINK); + if (!request.getProviderCode().equals( + assertion.provider().providerCode()) + || !request.getProviderCode().equals( + descriptor.providerCode())) { + throw reject( + actor, + request, + IdentityLinkFailureCode.INVALID_OPERATION); + } + UserAccount user = requireEligibleAccount(actor.userId()); + boolean alreadyLinked = bindingRepository + .findByUserIdAndStatus( + actor.userId(), + IdentityBindingStatus.ACTIVE) + .stream() + .anyMatch(binding -> binding.getProviderCode() + .equals(descriptor.providerCode())); + if (alreadyLinked) { + throw reject( + actor, + request, + IdentityLinkFailureCode.ALREADY_LINKED); + } + requireSubjectsUnbound( + actor, + request, + assertion, + descriptor); + + ExternalSubject legacySubject = assertion.requireUniqueSubject( + descriptor.legacyPrimarySubjectType()); + IdentityBinding binding = new IdentityBinding( + actor.userId(), + descriptor.providerCode(), + legacySubject.value(), + assertion.profile().displayName()); + binding.recordAuthentication( + assertion.evidence().authenticatedAt()); + IdentityBinding savedBinding = + bindingRepository.saveAndFlush(binding); + if (savedBinding.getId() == null) { + throw new IllegalStateException( + "Identity binding id was not assigned"); + } + List subjects = + assertion.allSubjects().stream() + .map(subject -> new IdentityBindingSubject( + savedBinding.getId(), + savedBinding.getProviderCode(), + subject.type(), + subject.value(), + subject.equals( + assertion.primarySubject()), + assertion.evidence() + .authenticatedAt())) + .toList(); + subjectRepository.saveAllAndFlush(subjects); + request.complete(now()); + recordAudit( + actor, + "IDENTITY_BINDING_LINKED", + request, + descriptor.providerCode()); + return new LinkedBinding( + principalFactory.create( + user, + actor.authenticationProvider()), + savedBinding.getId()); + } + + @Transactional(noRollbackFor = IdentityLinkException.class) + public IdentityLinkIntent completeUnlink( + IdentityLinkActor actor, + UUID intentId) { + IdentityLinkRequest request = requireRequest( + actor, + intentId); + requireReady( + request, + actor, + IdentityLinkOperation.UNLINK); + requireEligibleAccount(actor.userId()); + IdentityBinding binding = bindingRepository + .findByIdAndStatusForUpdate( + request.getTargetBindingId(), + IdentityBindingStatus.ACTIVE) + .filter(candidate -> candidate.getUserId() + .equals(actor.userId())) + .orElseThrow(() -> + reject( + actor, + request, + IdentityLinkFailureCode.ALREADY_CONSUMED)); + if (!hasOtherUsableLoginMethod( + actor.userId(), + binding.getId())) { + throw reject( + actor, + request, + IdentityLinkFailureCode.FINAL_LOGIN_METHOD); + } + + Instant revokedAt = now(); + List activeSubjects = + subjectRepository.findByBindingIdAndStatusForUpdate( + binding.getId(), + IdentityBindingSubjectStatus.ACTIVE); + if (activeSubjects.isEmpty()) { + throw reject( + actor, + request, + IdentityLinkFailureCode.ALREADY_CONSUMED); + } + activeSubjects.forEach(subject -> + subject.revoke(revokedAt)); + subjectRepository.saveAll(activeSubjects); + binding.revoke( + actor.userId(), + USER_UNLINK_REASON, + revokedAt); + bindingRepository.save(binding); + request.complete(revokedAt); + recordAudit( + actor, + "IDENTITY_BINDING_REVOKED", + request, + binding.getProviderCode()); + return toIntent(request); + } + + @Transactional(readOnly = true) + public IdentityLinkAccountState accountState(String userId) { + requireEligibleAccountForRead(userId); + List bindings = bindingRepository + .findByUserIdAndStatus( + userId, + IdentityBindingStatus.ACTIVE) + .stream() + .sorted(Comparator.comparing( + IdentityBinding::getProviderCode)) + .toList(); + Map readyProviders = + readyProviders(); + boolean localPasswordEnabled = + credentialRepository.existsByUserId(userId); + long usableMethodCount = + localPasswordEnabled ? 1 : 0; + usableMethodCount += bindings.stream() + .filter(binding -> readyProviders.containsKey( + binding.getProviderCode())) + .count(); + + long totalUsableMethodCount = usableMethodCount; + List linked = + bindings.stream() + .map(binding -> { + ReadyProvider provider = + readyProviders.get( + binding.getProviderCode()); + boolean usable = provider != null; + boolean anotherUsableMethod = + totalUsableMethodCount + - (usable ? 1 : 0) + > 0; + return new IdentityLinkBindingView( + binding.getId(), + binding.getProviderCode(), + provider == null + ? binding.getProviderCode() + : provider.displayName(), + provider == null + ? Set.of() + : provider.methodTypes(), + usable, + anotherUsableMethod); + }) + .toList(); + + Set linkedProviderCodes = bindings.stream() + .map(IdentityBinding::getProviderCode) + .collect(Collectors.toSet()); + List available = + readyProviders.values() + .stream() + .filter(provider -> + !linkedProviderCodes.contains( + provider.providerCode())) + .filter(provider -> + provider.methodTypes().contains( + IdentityProviderLoginMethodType + .OAUTH_REDIRECT) + || provider.methodTypes().contains( + IdentityProviderLoginMethodType + .DIRECT_PASSWORD)) + .sorted(Comparator.comparing( + ReadyProvider::providerCode)) + .map(provider -> + new IdentityLinkProviderView( + provider.providerCode(), + provider.displayName(), + provider.methodTypes())) + .toList(); + return new IdentityLinkAccountState( + localPasswordEnabled, + linked, + available); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordRejectedAfterRollback( + IdentityLinkActor actor, + UUID intentId, + IdentityLinkFailureCode code) { + requestRepository.findById(intentId) + .filter(request -> request.getPrimaryUserId() + .equals(actor.userId())) + .filter(request -> stateHasher.matches( + actor.sessionNonce(), + request.getStateHash())) + .ifPresent(request -> recordAudit( + actor, + "IDENTITY_LINK_INTENT_REJECTED", + request, + code.name().toLowerCase(Locale.ROOT))); + } + + private IdentityLinkRequest requireRequest( + IdentityLinkActor actor, + UUID intentId) { + IdentityLinkRequest request = requestRepository + .findByIdForUpdate(intentId) + .orElseThrow(() -> + failure( + IdentityLinkFailureCode + .INTENT_NOT_FOUND)); + if (!request.getPrimaryUserId().equals(actor.userId())) { + throw reject( + actor, + request, + IdentityLinkFailureCode.INTENT_NOT_FOUND); + } + if (!stateHasher.matches( + actor.sessionNonce(), + request.getStateHash())) { + throw reject( + actor, + request, + IdentityLinkFailureCode.SESSION_MISMATCH); + } + return request; + } + + private void requireActive( + IdentityLinkRequest request, + IdentityLinkActor actor) { + if (request.getStatus() == IdentityLinkRequestStatus.EXPIRED) { + throw reject( + actor, + request, + IdentityLinkFailureCode.INTENT_EXPIRED); + } + if (!request.getStatus().isActive()) { + throw reject( + actor, + request, + IdentityLinkFailureCode.ALREADY_CONSUMED); + } + if (request.isExpiredAt(now())) { + request.expire(now()); + recordAudit( + actor, + "IDENTITY_LINK_INTENT_EXPIRED", + request, + "expired"); + throw failure( + IdentityLinkFailureCode.INTENT_EXPIRED); + } + } + + private void requirePendingReauthentication( + IdentityLinkRequest request, + IdentityLinkActor actor) { + requireActive(request, actor); + if (request.getStatus() + != IdentityLinkRequestStatus + .PENDING_REAUTHENTICATION) { + throw reject( + actor, + request, + IdentityLinkFailureCode.ALREADY_CONSUMED); + } + } + + private void requireReady( + IdentityLinkRequest request, + IdentityLinkActor actor, + IdentityLinkOperation operation) { + requireActive(request, actor); + if (request.getOperation() != operation) { + throw reject( + actor, + request, + IdentityLinkFailureCode.INVALID_OPERATION); + } + if (request.getStatus() != IdentityLinkRequestStatus.READY) { + throw reject( + actor, + request, + IdentityLinkFailureCode.REAUTHENTICATION_REQUIRED); + } + } + + private UserAccount requireEligibleAccount(String userId) { + UserAccount user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> + failure( + IdentityLinkFailureCode + .ACCOUNT_NOT_ELIGIBLE)); + if (accountLoginGuard.evaluateInteractive(user) + != AccountLoginDecision.ALLOWED) { + throw failure( + IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE); + } + return user; + } + + private UserAccount requireEligibleAccountForRead(String userId) { + UserAccount user = userRepository.findById(userId) + .orElseThrow(() -> + failure( + IdentityLinkFailureCode + .ACCOUNT_NOT_ELIGIBLE)); + if (accountLoginGuard.evaluateInteractive(user) + != AccountLoginDecision.ALLOWED) { + throw failure( + IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE); + } + return user; + } + + private void requireReadyLinkProvider(String providerCode) { + if (providerCode == null || providerCode.isBlank()) { + throw failure( + IdentityLinkFailureCode.INVALID_OPERATION); + } + ReadyProvider provider = readyProviders().get(providerCode); + if (provider == null + || (provider.methodTypes().stream().noneMatch(type -> + type == IdentityProviderLoginMethodType.OAUTH_REDIRECT + || type == IdentityProviderLoginMethodType + .DIRECT_PASSWORD))) { + throw failure( + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); + } + } + + private void requireProviderCapability( + IdentityLinkActor actor, + IdentityLinkRequest request, + String providerCode, + IdentityProviderLoginMethodType methodType) { + ReadyProvider provider = + readyProviders().get(providerCode); + if (provider == null + || !provider.methodTypes().contains(methodType)) { + throw reject( + actor, + request, + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); + } + } + + private Map readyProviders() { + Map accumulated = + new LinkedHashMap<>(); + for (IdentityProviderLoginMethod method + : providerRegistry.listReadyLoginMethods()) { + accumulated.computeIfAbsent( + method.providerCode(), + ignored -> new ProviderAccumulator( + method.providerCode(), + method.displayName())) + .methodTypes() + .add(method.methodType()); + } + LinkedHashMap providers = + new LinkedHashMap<>(); + accumulated.values().forEach(provider -> + providers.put( + provider.providerCode(), + new ReadyProvider( + provider.providerCode(), + provider.displayName(), + Set.copyOf( + provider.methodTypes())))); + return Map.copyOf(providers); + } + + private IdentityBinding resolveAuthenticatedBinding( + IdentityAssertion assertion, + ProviderDescriptor descriptor) { + List typedMatches = + subjectRepository.findMatchingSubjects( + assertion.provider().providerCode(), + subjectValuesByType(assertion.allSubjects())); + ExternalSubject legacySubject = assertion.requireUniqueSubject( + descriptor.legacyPrimarySubjectType()); + IdentityBinding legacyMatch = bindingRepository + .findByProviderCodeAndSubjectAndStatus( + assertion.provider().providerCode(), + legacySubject.value(), + IdentityBindingStatus.ACTIVE) + .orElse(null); + + LinkedHashSet activeBindingIds = typedMatches.stream() + .filter(subject -> subject.getStatus() + == IdentityBindingSubjectStatus.ACTIVE) + .map(IdentityBindingSubject::getBindingId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (legacyMatch != null + && legacyMatch.getStatus() + == IdentityBindingStatus.ACTIVE) { + activeBindingIds.add(legacyMatch.getId()); + } + if (activeBindingIds.size() != 1) { + throw failure( + IdentityLinkFailureCode.ACCOUNT_NOT_ELIGIBLE); + } + return bindingRepository + .findByIdAndStatusForUpdate( + activeBindingIds.getFirst(), + IdentityBindingStatus.ACTIVE) + .orElseThrow(() -> + failure( + IdentityLinkFailureCode + .ACCOUNT_NOT_ELIGIBLE)); + } + + private void requireSubjectsUnbound( + IdentityLinkActor actor, + IdentityLinkRequest request, + IdentityAssertion assertion, + ProviderDescriptor descriptor) { + List matches = + subjectRepository.findMatchingSubjects( + assertion.provider().providerCode(), + subjectValuesByType(assertion.allSubjects())); + ExternalSubject legacySubject = assertion.requireUniqueSubject( + descriptor.legacyPrimarySubjectType()); + boolean activeSubjectExists = matches.stream() + .anyMatch(subject -> subject.getStatus() + == IdentityBindingSubjectStatus.ACTIVE); + boolean activeLegacyBindingExists = bindingRepository + .findByProviderCodeAndSubjectAndStatus( + assertion.provider().providerCode(), + legacySubject.value(), + IdentityBindingStatus.ACTIVE) + .isPresent(); + if (activeSubjectExists || activeLegacyBindingExists) { + throw reject( + actor, + request, + IdentityLinkFailureCode.IDENTITY_IN_USE); + } + } + + private void requireNoActiveRequest( + IdentityLinkActor actor, + Instant now) { + requestRepository + .findActiveByPrimaryUserIdForUpdate( + actor.userId(), + Set.of( + IdentityLinkRequestStatus + .PENDING_REAUTHENTICATION, + IdentityLinkRequestStatus.READY)) + .ifPresent(active -> { + if (!active.isExpiredAt(now)) { + throw reject( + actor, + active, + IdentityLinkFailureCode + .ACTIVE_INTENT_EXISTS); + } + active.expire(now); + recordAudit( + actor, + "IDENTITY_LINK_INTENT_EXPIRED", + active, + "expired"); + requestRepository.flush(); + }); + } + + private boolean hasOtherUsableLoginMethod( + String userId, + long excludedBindingId) { + if (credentialRepository.existsByUserId(userId)) { + return true; + } + Set readyProviderCodes = + readyProviders().keySet(); + return bindingRepository + .findByUserIdAndStatus( + userId, + IdentityBindingStatus.ACTIVE) + .stream() + .filter(binding -> binding.getId() + != excludedBindingId) + .map(IdentityBinding::getProviderCode) + .anyMatch(readyProviderCodes::contains); + } + + private Map> subjectValuesByType( + Set subjects) { + LinkedHashMap> valuesByType = + new LinkedHashMap<>(); + for (ExternalSubject subject : subjects) { + valuesByType.computeIfAbsent( + subject.type(), + ignored -> new LinkedHashSet<>()) + .add(subject.value()); + } + return Map.copyOf(valuesByType); + } + + private void recordAudit( + IdentityLinkActor actor, + String action, + IdentityLinkRequest request, + String result) { + IdentityLoginContext context = actor.auditContext(); + auditLogService.record( + actor.userId(), + action, + "IDENTITY_LINK_REQUEST", + null, + context.requestId(), + context.clientIp(), + context.userAgent(), + "{\"intentId\":\"" + + request.getId() + + "\",\"operation\":\"" + + request.getOperation() + + "\",\"providerCode\":\"" + + request.getProviderCode() + + "\",\"result\":\"" + + result + + "\"}"); + } + + private IdentityLinkIntent toIntent(IdentityLinkRequest request) { + return new IdentityLinkIntent( + request.getId(), + request.getOperation(), + request.getStatus(), + request.getProviderCode(), + request.getTargetBindingId(), + request.getExpiresAt()); + } + + private Instant now() { + return Instant.now(clock); + } + + private IdentityLinkException failure( + IdentityLinkFailureCode code) { + return new IdentityLinkException(code); + } + + private IdentityLinkException reject( + IdentityLinkActor actor, + IdentityLinkRequest request, + IdentityLinkFailureCode code) { + recordAudit( + actor, + "IDENTITY_LINK_INTENT_REJECTED", + request, + code.name().toLowerCase(Locale.ROOT)); + return failure(code); + } + + record LinkedBinding( + PlatformPrincipal principal, + long bindingId) { + } + + private record ReadyProvider( + String providerCode, + String displayName, + Set methodTypes) { + } + + private record ProviderAccumulator( + String providerCode, + String displayName, + Set methodTypes) { + private ProviderAccumulator( + String providerCode, + String displayName) { + this( + providerCode, + displayName, + new LinkedHashSet<>()); + } + } +} 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 5496e010..6192f21c 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 @@ -131,9 +131,10 @@ class IdentityResolutionTransaction { assertion.provider().providerCode(), subjectValuesByType(assertion.allSubjects())); IdentityBinding legacyMatch = bindingRepository - .findByProviderCodeAndSubject( + .findByProviderCodeAndSubjectAndStatus( assertion.provider().providerCode(), - legacySubject.value()) + legacySubject.value(), + IdentityBindingStatus.ACTIVE) .orElse(null); LinkedHashSet activeBindingIds = typedMatches.stream() diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index e1ebdb86..6c63b374 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -110,10 +110,11 @@ public class LocalAuthService { * Authenticates a local account and returns the principal snapshot used to * establish a web session. */ - @Transactional + @Transactional(noRollbackFor = AuthFlowException.class) public PlatformPrincipal login(String username, String password) { String normalizedUsername = normalizeUsername(username); - LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername) + LocalCredential credential = credentialRepository + .findByUsernameIgnoreCaseForUpdate(normalizedUsername) .orElse(null); if (credential == null) { @@ -139,6 +140,39 @@ public class LocalAuthService { return principalFactory.create(user, "local"); } + /** + * Reauthenticates the already authenticated account without creating, + * replacing, or rotating its web session. + */ + @Transactional(noRollbackFor = AuthFlowException.class) + public PlatformPrincipal reauthenticate( + String userId, + String password) { + LocalCredential credential = credentialRepository + .findByUserIdForUpdate(userId) + .orElseThrow(() -> new AuthFlowException( + HttpStatus.BAD_REQUEST, + "error.auth.local.notEnabled")); + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new IllegalStateException( + "User not found for local credential")); + + requireLocalLoginAllowed( + accountLoginGuard.evaluateInteractive(user)); + ensureNotLocked(credential); + if (!passwordEncoder.matches( + password == null ? "" : password, + credential.getPasswordHash())) { + handleFailedLogin(credential); + throw invalidCredentials(); + } + + credential.setFailedAttempts(0); + credential.setLockedUntil(null); + credentialRepository.save(credential); + return principalFactory.create(user, "local"); + } + /** * Changes the stored password for an already authenticated local account. */ diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java index a80d44ac..b55e67f9 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredentialRepository.java @@ -1,7 +1,11 @@ package com.iflytek.skillhub.auth.local; +import jakarta.persistence.LockModeType; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +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; /** @@ -14,6 +18,24 @@ public interface LocalCredentialRepository extends JpaRepository findByUserId(String userId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select credential + from LocalCredential credential + where lower(credential.username) = lower(:username) + """) + Optional findByUsernameIgnoreCaseForUpdate( + @Param("username") String username); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select credential + from LocalCredential credential + where credential.userId = :userId + """) + Optional findByUserIdForUpdate( + @Param("userId") String userId); + boolean existsByUsernameIgnoreCase(String username); boolean existsByUserId(String userId); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilter.java index 8ead2c02..c1386f6a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilter.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.auth.oauth; import com.iflytek.skillhub.auth.identity.IdentityCoreException; import com.iflytek.skillhub.auth.identity.IdentityFailureCode; import com.iflytek.skillhub.auth.identity.IdentityProviderReadinessService; +import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -29,12 +30,15 @@ public final class IdentityProviderRouteReadinessFilter "/login/oauth2/code/"; private final ClientRegistrationRepository registrationRepository; private final IdentityProviderReadinessService readinessService; + private final OAuth2LoginFailureHandler failureHandler; public IdentityProviderRouteReadinessFilter( ClientRegistrationRepository registrationRepository, - IdentityProviderReadinessService readinessService) { + IdentityProviderReadinessService readinessService, + OAuth2LoginFailureHandler failureHandler) { this.registrationRepository = registrationRepository; this.readinessService = readinessService; + this.failureHandler = failureHandler; } @Override @@ -55,6 +59,9 @@ public final class IdentityProviderRouteReadinessFilter ? null : registrationRepository.findByRegistrationId(registrationId); if (registration == null) { + if (redirectIdentityLinkFailure(request, response)) { + return; + } response.setStatus(HttpServletResponse.SC_FORBIDDEN); return; } @@ -62,6 +69,9 @@ public final class IdentityProviderRouteReadinessFilter try { readinessService.requireReady(registration); } catch (IdentityCoreException exception) { + if (redirectIdentityLinkFailure(request, response)) { + return; + } int status = exception.getReasonCode() == IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH ? HttpServletResponse.SC_SERVICE_UNAVAILABLE @@ -77,6 +87,9 @@ public final class IdentityProviderRouteReadinessFilter "Identity provider route '{}' readiness check failed", registration.getRegistrationId(), exception); + if (redirectIdentityLinkFailure(request, response)) { + return; + } response.setStatus( HttpServletResponse.SC_SERVICE_UNAVAILABLE); return; @@ -84,6 +97,16 @@ public final class IdentityProviderRouteReadinessFilter filterChain.doFilter(request, response); } + private boolean redirectIdentityLinkFailure( + HttpServletRequest request, + HttpServletResponse response) + throws IOException { + return failureHandler.redirectIdentityLinkRouteFailure( + request, + response, + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); + } + private String registrationId(HttpServletRequest request) { String path = pathWithinApplication(request); String value = pathSegment(path, AUTHORIZATION_PREFIX); 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 14beac75..413622d4 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 @@ -1,33 +1,88 @@ package com.iflytek.skillhub.auth.oauth; +import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager; +import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; -import java.io.IOException; - /** * Failure handler for OAuth logins that normalizes policy and account-state * failures into predictable user-facing redirects. */ @Component -public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { +public class OAuth2LoginFailureHandler + extends SimpleUrlAuthenticationFailureHandler { private final OAuthLoginFlowService oauthLoginFlowService; + private final IdentityLinkSessionManager identityLinkSessionManager; - public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) { + public OAuth2LoginFailureHandler( + OAuthLoginFlowService oauthLoginFlowService, + IdentityLinkSessionManager identityLinkSessionManager) { this.oauthLoginFlowService = oauthLoginFlowService; + this.identityLinkSessionManager = identityLinkSessionManager; + } + + /** + * Converts a pre-upstream route failure into an Identity Link callback + * result only when this session actually owns a pending browser flow. + * Normal OAuth login readiness failures retain their existing HTTP + * status behavior. + */ + public boolean redirectIdentityLinkRouteFailure( + HttpServletRequest request, + HttpServletResponse response, + IdentityLinkFailureCode reasonCode) + throws IOException { + var session = request.getSession(false); + var intentId = identityLinkSessionManager + .consumeFailedBrowserFlow(session); + if (intentId.isEmpty()) { + return false; + } + oauthLoginFlowService.consumeReturnTo(session); + getRedirectStrategy().sendRedirect( + request, + response, + "/settings/security?identityLink=failed" + + "&intentId=" + + intentId.get() + + "&reasonCode=" + + reasonCode.name()); + return true; } @Override - public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, - AuthenticationException exception) + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { - String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false)); - String redirectTarget = oauthLoginFlowService.resolveFailureRedirect(exception, returnTo); + var session = request.getSession(false); + String returnTo = oauthLoginFlowService.consumeReturnTo(session); + String reasonCode = oauthLoginFlowService + .identityLinkFailureReasonCode(exception) + .orElse( + IdentityLinkFailureCode + .PROVIDER_AUTHENTICATION_FAILED + .name()); + String redirectTarget = identityLinkSessionManager + .consumeFailedBrowserFlow(session) + .map(intentId -> + "/settings/security?identityLink=failed" + + "&intentId=" + + intentId + + "&reasonCode=" + + reasonCode) + .orElseGet(() -> + oauthLoginFlowService.resolveFailureRedirect( + exception, + returnTo)); if (redirectTarget != null) { getRedirectStrategy().sendRedirect(request, response, redirectTarget); return; 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 2940d02e..225300be 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 @@ -1,8 +1,15 @@ package com.iflytek.skillhub.auth.oauth; import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService; +import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService; import com.iflytek.skillhub.auth.identity.IdentityCoreException; import com.iflytek.skillhub.auth.identity.IdentityFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserFlow; +import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase; +import com.iflytek.skillhub.auth.identity.IdentityLinkException; +import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome; +import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager; import com.iflytek.skillhub.auth.identity.IdentityLoginContext; import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome; import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; @@ -16,6 +23,8 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; @@ -28,6 +37,8 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; /** * Flow owner for browser OAuth login. It centralizes the stages of remembering @@ -41,15 +52,21 @@ public class OAuthLoginFlowService { private final Map extractors; private final TrustedProviderRouteResolver providerRouteResolver; private final ExternalIdentityLoginService identityLoginService; + private final ExternalIdentityLinkService identityLinkService; + private final IdentityLinkSessionManager identityLinkSessionManager; @Autowired public OAuthLoginFlowService(List extractorList, TrustedProviderRouteResolver providerRouteResolver, - ExternalIdentityLoginService identityLoginService) { + ExternalIdentityLoginService identityLoginService, + ExternalIdentityLinkService identityLinkService, + IdentityLinkSessionManager identityLinkSessionManager) { this( extractorList, providerRouteResolver, identityLoginService, + identityLinkService, + identityLinkSessionManager, new DefaultOAuth2UserService()); } @@ -57,6 +74,8 @@ public class OAuthLoginFlowService { List extractorList, TrustedProviderRouteResolver providerRouteResolver, ExternalIdentityLoginService identityLoginService, + ExternalIdentityLinkService identityLinkService, + IdentityLinkSessionManager identityLinkSessionManager, OAuth2UserService delegate) { this.extractors = extractorList.stream() .collect(Collectors.toMap( @@ -64,6 +83,8 @@ public class OAuthLoginFlowService { Function.identity())); this.providerRouteResolver = providerRouteResolver; this.identityLoginService = identityLoginService; + this.identityLinkService = identityLinkService; + this.identityLinkSessionManager = identityLinkSessionManager; this.delegate = Objects.requireNonNull(delegate, "delegate"); } @@ -116,6 +137,14 @@ public class OAuthLoginFlowService { ProviderAuthenticationResult result, IdentityLoginContext context) { try { + Optional identityLinkFlow = + consumeIdentityLinkFlow(provider, context); + if (identityLinkFlow.isPresent()) { + return authenticateIdentityLinkFlow( + identityLinkFlow.get(), + provider, + result); + } IdentityLoginOutcome outcome = identityLoginService.authenticate( provider, result, @@ -132,9 +161,69 @@ public class OAuthLoginFlowService { null)); } catch (IdentityCoreException exception) { throw mapIdentityFailure(exception); + } catch (IdentityLinkException exception) { + throw oauthFailure( + "identity_link_failed", + exception.getReasonCode().name(), + exception); } } + private Optional consumeIdentityLinkFlow( + ResolvedProviderHandle provider, + IdentityLoginContext context) { + if (!(RequestContextHolder.getRequestAttributes() + instanceof ServletRequestAttributes attributes)) { + return Optional.empty(); + } + return identityLinkSessionManager.consumeBrowserFlow( + attributes.getRequest(), + provider.providerCode(), + context); + } + + private PlatformPrincipal authenticateIdentityLinkFlow( + IdentityLinkBrowserFlow flow, + ResolvedProviderHandle provider, + ProviderAuthenticationResult result) { + IdentityLinkOutcome outcome; + if (flow.phase() + == IdentityLinkBrowserPhase.REAUTHENTICATE) { + outcome = identityLinkService.reauthenticate( + flow.actor(), + flow.intentId(), + provider, + result); + } else { + outcome = identityLinkService.link( + flow.actor(), + flow.intentId(), + provider, + result); + } + if (outcome + instanceof IdentityLinkOutcome.Reauthenticated completed) { + return completed.principal(); + } + if (outcome instanceof IdentityLinkOutcome.Linked linked) { + currentRequest().ifPresent(request -> + identityLinkSessionManager.remove( + request.getSession(false), + flow.intentId())); + return linked.principal(); + } + throw new IllegalStateException( + "Unsupported identity link outcome"); + } + + private Optional currentRequest() { + if (RequestContextHolder.getRequestAttributes() + instanceof ServletRequestAttributes attributes) { + return Optional.of(attributes.getRequest()); + } + return Optional.empty(); + } + public void rememberReturnTo(HttpServletRequest request) { String returnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(request.getParameter("returnTo")); HttpSession session = request.getSession(); @@ -178,12 +267,78 @@ public class OAuthLoginFlowService { oauth2Exception.getError().getErrorCode()))) { return "/access-denied"; } + if (exception instanceof OAuth2AuthenticationException oauth2Exception + && "identity_link_failed".equals( + oauth2Exception.getError().getErrorCode())) { + return identityLinkFailureRedirect( + returnTo, + identityLinkFailureReasonCode(exception) + .orElse(null)); + } if (returnTo != null) { return "/login?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8); } return null; } + public Optional identityLinkFailureReasonCode( + AuthenticationException exception) { + if (!(exception + instanceof OAuth2AuthenticationException oauth2Exception) + || !"identity_link_failed".equals( + oauth2Exception.getError().getErrorCode())) { + return Optional.empty(); + } + String description = + oauth2Exception.getError().getDescription(); + try { + return Optional.of( + IdentityLinkFailureCode.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 Optional identityLinkIntentId(String returnTo) { + if (returnTo == null + || !returnTo.startsWith("/settings/security?")) { + 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(); + } + 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 c72b1d9d..3899c6eb 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,5 +1,6 @@ package com.iflytek.skillhub.auth.oauth; +import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager; import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; @@ -16,27 +17,45 @@ public class SkillHubOAuth2AuthorizationRequestResolver private final DefaultOAuth2AuthorizationRequestResolver delegate; private final OAuthLoginFlowService oauthLoginFlowService; + private final IdentityLinkSessionManager identityLinkSessionManager; public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository, - OAuthLoginFlowService oauthLoginFlowService) { + OAuthLoginFlowService oauthLoginFlowService, + IdentityLinkSessionManager identityLinkSessionManager) { this.delegate = new DefaultOAuth2AuthorizationRequestResolver( clientRegistrationRepository, "/oauth2/authorization" ); this.oauthLoginFlowService = oauthLoginFlowService; + this.identityLinkSessionManager = identityLinkSessionManager; } @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request); - oauthLoginFlowService.rememberReturnTo(request); + rememberAuthorizationFlow(request, authorizationRequest); return authorizationRequest; } @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId); - oauthLoginFlowService.rememberReturnTo(request); + rememberAuthorizationFlow(request, authorizationRequest); return authorizationRequest; } + + private void rememberAuthorizationFlow( + HttpServletRequest request, + OAuth2AuthorizationRequest authorizationRequest) { + if (authorizationRequest == null) { + return; + } + oauthLoginFlowService.rememberReturnTo(request); + String registrationId = authorizationRequest.getAttribute( + "registration_id"); + identityLinkSessionManager.activateBrowserFlow( + request.getSession(false), + registrationId, + authorizationRequest.getState()); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java index fc0414a5..49492c23 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java @@ -16,7 +16,10 @@ import org.springframework.stereotype.Repository; */ @Repository public interface IdentityBindingRepository extends JpaRepository { - Optional findByProviderCodeAndSubject(String providerCode, String subject); + Optional findByProviderCodeAndSubjectAndStatus( + String providerCode, + String subject, + IdentityBindingStatus status); @Query(""" select distinct binding.providerCode @@ -39,4 +42,8 @@ public interface IdentityBindingRepository extends JpaRepository findByUserId(String userId); + + List findByUserIdAndStatus( + String userId, + IdentityBindingStatus status); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityLinkRequestRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityLinkRequestRepository.java new file mode 100644 index 00000000..5545e2ba --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityLinkRequestRepository.java @@ -0,0 +1,39 @@ +package com.iflytek.skillhub.auth.repository; + +import com.iflytek.skillhub.auth.entity.IdentityLinkRequest; +import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus; +import jakarta.persistence.LockModeType; +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +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 IdentityLinkRequestRepository + extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select request + from IdentityLinkRequest request + where request.id = :requestId + """) + Optional findByIdForUpdate( + @Param("requestId") UUID requestId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select request + from IdentityLinkRequest request + where request.primaryUserId = :userId + and request.status in :statuses + """) + Optional findActiveByPrimaryUserIdForUpdate( + @Param("userId") String userId, + @Param("statuses") + Collection statuses); +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequestTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequestTest.java new file mode 100644 index 00000000..d00fa8cc --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/entity/IdentityLinkRequestTest.java @@ -0,0 +1,95 @@ +package com.iflytek.skillhub.auth.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class IdentityLinkRequestTest { + + private static final Instant CREATED_AT = + Instant.parse("2026-07-31T08:00:00Z"); + private static final String STATE_HASH = "a".repeat(64); + + @Test + void linkRequestRequiresNoTargetBindingAndStartsPending() { + IdentityLinkRequest request = new IdentityLinkRequest( + UUID.randomUUID(), + "usr_1", + IdentityLinkOperation.LINK, + "github", + null, + STATE_HASH, + CREATED_AT.plusSeconds(600), + CREATED_AT); + + assertThat(request.getStatus()) + .isEqualTo( + IdentityLinkRequestStatus + .PENDING_REAUTHENTICATION); + assertThat(request.getTargetBindingId()).isNull(); + } + + @Test + void unlinkRequestRequiresTargetBinding() { + assertThatThrownBy(() -> + new IdentityLinkRequest( + UUID.randomUUID(), + "usr_1", + IdentityLinkOperation.UNLINK, + "github", + null, + STATE_HASH, + CREATED_AT.plusSeconds(600), + CREATED_AT)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void requestCanOnlyBeReauthenticatedAndCompletedOnce() { + IdentityLinkRequest request = request(); + request.markReauthenticated( + "local-password", + CREATED_AT.plusSeconds(10)); + request.complete(CREATED_AT.plusSeconds(20)); + + assertThat(request.getStatus()) + .isEqualTo(IdentityLinkRequestStatus.COMPLETED); + assertThatThrownBy(() -> + request.complete(CREATED_AT.plusSeconds(30))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> + request.markReauthenticated( + "local-password", + CREATED_AT.plusSeconds(30))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void expiredRequestCannotReturnToAnActiveState() { + IdentityLinkRequest request = request(); + request.expire(CREATED_AT.plusSeconds(600)); + + assertThat(request.getStatus()) + .isEqualTo(IdentityLinkRequestStatus.EXPIRED); + assertThatThrownBy(() -> + request.markReauthenticated( + "local-password", + CREATED_AT.plusSeconds(601))) + .isInstanceOf(IllegalStateException.class); + } + + private IdentityLinkRequest request() { + return new IdentityLinkRequest( + UUID.randomUUID(), + "usr_1", + IdentityLinkOperation.LINK, + "github", + null, + STATE_HASH, + CREATED_AT.plusSeconds(600), + CREATED_AT); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentServiceTest.java new file mode 100644 index 00000000..63b440fb --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkIntentServiceTest.java @@ -0,0 +1,123 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.IdentityLinkOperation; +import com.iflytek.skillhub.auth.entity.IdentityLinkRequestStatus; +import com.iflytek.skillhub.auth.local.LocalAuthService; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class IdentityLinkIntentServiceTest { + + @Mock + private IdentityLinkTransaction transaction; + + @Mock + private LocalAuthService localAuthService; + + @Test + void localReauthenticationValidatesIntentBeforeCheckingPassword() { + IdentityLinkIntentService service = + new IdentityLinkIntentService( + transaction, + localAuthService); + IdentityLinkActor actor = actor(); + UUID intentId = UUID.randomUUID(); + IdentityLinkIntent pending = intent( + intentId, + IdentityLinkRequestStatus.PENDING_REAUTHENTICATION); + IdentityLinkIntent ready = intent( + intentId, + IdentityLinkRequestStatus.READY); + when(transaction.getIntent(actor, intentId)) + .thenReturn(pending); + when(transaction.markLocalReauthenticated( + actor, + intentId)) + .thenReturn(ready); + + IdentityLinkIntent result = service.reauthenticateLocal( + actor, + intentId, + "current-password"); + + assertThat(result).isSameAs(ready); + InOrder order = inOrder( + transaction, + localAuthService); + order.verify(transaction).getIntent(actor, intentId); + order.verify(localAuthService).reauthenticate( + actor.userId(), + "current-password"); + order.verify(transaction).markLocalReauthenticated( + actor, + intentId); + } + + @Test + void consumedIntentDoesNotCheckPassword() { + IdentityLinkIntentService service = + new IdentityLinkIntentService( + transaction, + localAuthService); + IdentityLinkActor actor = actor(); + UUID intentId = UUID.randomUUID(); + when(transaction.getIntent(actor, intentId)) + .thenReturn(intent( + intentId, + IdentityLinkRequestStatus.READY)); + + assertThatThrownBy(() -> + service.reauthenticateLocal( + actor, + intentId, + "current-password")) + .isInstanceOfSatisfying( + IdentityLinkException.class, + exception -> assertThat( + exception.getReasonCode()) + .isEqualTo( + IdentityLinkFailureCode + .ALREADY_CONSUMED)); + + verifyNoInteractions(localAuthService); + verify(transaction, never()) + .markLocalReauthenticated(actor, intentId); + } + + private IdentityLinkActor actor() { + return new IdentityLinkActor( + "usr_1", + "local", + "session-nonce", + new IdentityLoginContext( + "req-1", + "203.0.113.9", + "Identity Link Test")); + } + + private IdentityLinkIntent intent( + UUID intentId, + IdentityLinkRequestStatus status) { + return new IdentityLinkIntent( + intentId, + IdentityLinkOperation.LINK, + status, + "github", + null, + Instant.parse("2026-07-31T08:10:00Z")); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManagerTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManagerTest.java new file mode 100644 index 00000000..1bf78757 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLinkSessionManagerTest.java @@ -0,0 +1,209 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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 IdentityLinkSessionManagerTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-07-31T08:00:00Z"), + ZoneOffset.UTC); + private static final IdentityLoginContext CONTEXT = + new IdentityLoginContext( + "req-1", + "203.0.113.9", + "Browser"); + + private IdentityLinkSessionManager manager; + private MockHttpSession session; + + @BeforeEach + void setUp() { + manager = new IdentityLinkSessionManager( + new SecureRandom(), + new IdentityLinkStateHasher(), + CLOCK); + session = new MockHttpSession(); + session.setAttribute( + "platformPrincipal", + new PlatformPrincipal( + "usr_1", + "Alice", + "alice@example.com", + null, + "local", + Set.of("USER"))); + } + + @Test + void generatedNonceStaysInSessionAndIsOmittedFromActorString() { + UUID intentId = UUID.randomUUID(); + + IdentityLinkActor actor = manager.start( + session, + intentId, + CONTEXT); + + assertThat(actor.userId()).isEqualTo("usr_1"); + assertThat(actor.toString()) + .contains("usr_1") + .doesNotContain("nonce"); + assertThat(manager.actor(session, intentId, CONTEXT).userId()) + .isEqualTo("usr_1"); + } + + @Test + void anotherSessionCannotResumeIntent() { + UUID intentId = UUID.randomUUID(); + manager.start(session, intentId, CONTEXT); + MockHttpSession otherSession = new MockHttpSession(); + otherSession.setAttribute( + "platformPrincipal", + session.getAttribute("platformPrincipal")); + + assertThatThrownBy(() -> + manager.actor(otherSession, intentId, CONTEXT)) + .isInstanceOfSatisfying( + IdentityLinkException.class, + exception -> assertThat( + exception.getReasonCode()) + .isEqualTo( + IdentityLinkFailureCode + .SESSION_MISMATCH)); + } + + @Test + void browserFlowIsBoundToProviderOAuthStateAndConsumedOnce() { + UUID intentId = UUID.randomUUID(); + manager.start(session, intentId, CONTEXT); + manager.prepareBrowserFlow( + session, + intentId, + IdentityLinkBrowserPhase.LINK, + "github", + CONTEXT); + manager.activateBrowserFlow( + session, + "github", + "oauth-state"); + MockHttpServletRequest callback = + new MockHttpServletRequest( + "GET", + "/login/oauth2/code/github"); + callback.setSession(session); + callback.setParameter("state", "oauth-state"); + + IdentityLinkBrowserFlow flow = + manager.consumeBrowserFlow( + callback, + "github", + CONTEXT) + .orElseThrow(); + + assertThat(flow.intentId()).isEqualTo(intentId); + assertThat(flow.phase()) + .isEqualTo(IdentityLinkBrowserPhase.LINK); + assertThat(manager.consumeBrowserFlow( + callback, + "github", + CONTEXT)).isEmpty(); + } + + @Test + void mismatchedOAuthStateFailsClosedAndCannotBeRetried() { + UUID intentId = UUID.randomUUID(); + manager.start(session, intentId, CONTEXT); + manager.prepareBrowserFlow( + session, + intentId, + IdentityLinkBrowserPhase.REAUTHENTICATE, + "github", + CONTEXT); + manager.activateBrowserFlow( + session, + "github", + "expected-state"); + MockHttpServletRequest callback = + new MockHttpServletRequest( + "GET", + "/login/oauth2/code/github"); + callback.setSession(session); + callback.setParameter("state", "different-state"); + + assertThatThrownBy(() -> + manager.consumeBrowserFlow( + callback, + "github", + CONTEXT)) + .isInstanceOfSatisfying( + IdentityLinkException.class, + exception -> assertThat( + exception.getReasonCode()) + .isEqualTo( + IdentityLinkFailureCode + .SESSION_MISMATCH)); + assertThat(manager.consumeBrowserFlow( + callback, + "github", + CONTEXT)).isEmpty(); + } + + @Test + void failedBrowserFlowKeepsIntentAndCanBeRetried() { + UUID intentId = UUID.randomUUID(); + manager.start(session, intentId, CONTEXT); + manager.prepareBrowserFlow( + session, + intentId, + IdentityLinkBrowserPhase.LINK, + "github", + CONTEXT); + manager.activateBrowserFlow( + session, + "github", + "oauth-state"); + + assertThat(manager.consumeFailedBrowserFlow(session)) + .contains(intentId); + assertThat(manager.consumeFailedBrowserFlow(session)) + .isEmpty(); + assertThat(manager.actor(session, intentId, CONTEXT).userId()) + .isEqualTo("usr_1"); + + manager.prepareBrowserFlow( + session, + intentId, + IdentityLinkBrowserPhase.LINK, + "github", + CONTEXT); + manager.activateBrowserFlow( + session, + "github", + "retry-state"); + MockHttpServletRequest retryCallback = + new MockHttpServletRequest( + "GET", + "/login/oauth2/code/github"); + retryCallback.setSession(session); + retryCallback.setParameter("state", "retry-state"); + + assertThat(manager.consumeBrowserFlow( + retryCallback, + "github", + CONTEXT)) + .map(IdentityLinkBrowserFlow::intentId) + .contains(intentId); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java index 862ee8e5..eda56716 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java @@ -80,7 +80,8 @@ class IdentityResolutionTransactionTest { auditLogService); when(subjectRepository.findMatchingSubjects(any(), any())) .thenReturn(List.of()); - when(bindingRepository.findByProviderCodeAndSubject( + when(bindingRepository.findByProviderCodeAndSubjectAndStatus( + any(), any(), any())).thenReturn(Optional.empty()); when(userRepository.save(any(UserAccount.class))) @@ -309,9 +310,11 @@ class IdentityResolutionTransactionTest { "123456", true); UserAccount user = user("usr_1", UserStatus.ACTIVE, false); - when(bindingRepository.findByProviderCodeAndSubject( + when(bindingRepository.findByProviderCodeAndSubjectAndStatus( "github", - "123456")).thenReturn(Optional.of(binding)); + "123456", + IdentityBindingStatus.ACTIVE)) + .thenReturn(Optional.of(binding)); when(bindingRepository.findByIdAndStatusForUpdate( 1L, IdentityBindingStatus.ACTIVE)) @@ -394,9 +397,11 @@ class IdentityResolutionTransactionTest { when(subjectRepository.findMatchingSubjects( org.mockito.ArgumentMatchers.eq("provider"), any())).thenReturn(List.of(alias, stable)); - when(bindingRepository.findByProviderCodeAndSubject( + when(bindingRepository.findByProviderCodeAndSubjectAndStatus( "provider", - "legacy-123")).thenReturn(Optional.of(binding)); + "legacy-123", + IdentityBindingStatus.ACTIVE)) + .thenReturn(Optional.of(binding)); when(bindingRepository.findByIdAndStatusForUpdate( 1L, IdentityBindingStatus.ACTIVE)) @@ -507,9 +512,11 @@ class IdentityResolutionTransactionTest { "github", "123456"); UserAccount user = user("usr_1", UserStatus.PENDING, false); - when(bindingRepository.findByProviderCodeAndSubject( + when(bindingRepository.findByProviderCodeAndSubjectAndStatus( "github", - "123456")).thenReturn(Optional.of(binding)); + "123456", + IdentityBindingStatus.ACTIVE)) + .thenReturn(Optional.of(binding)); when(bindingRepository.findByIdAndStatusForUpdate( 1L, IdentityBindingStatus.ACTIVE)) @@ -554,9 +561,11 @@ class IdentityResolutionTransactionTest { "github", "123456"); UserAccount user = user("usr_1", UserStatus.PENDING, false); - when(bindingRepository.findByProviderCodeAndSubject( + when(bindingRepository.findByProviderCodeAndSubjectAndStatus( "github", - "123456")).thenReturn(Optional.of(binding)); + "123456", + IdentityBindingStatus.ACTIVE)) + .thenReturn(Optional.of(binding)); when(bindingRepository.findByIdAndStatusForUpdate( 1L, IdentityBindingStatus.ACTIVE)) @@ -614,9 +623,11 @@ class IdentityResolutionTransactionTest { "github", "123456"); UserAccount user = user("usr_blocked", status, system); - when(bindingRepository.findByProviderCodeAndSubject( + when(bindingRepository.findByProviderCodeAndSubjectAndStatus( "github", - "123456")).thenReturn(Optional.of(binding)); + "123456", + IdentityBindingStatus.ACTIVE)) + .thenReturn(Optional.of(binding)); when(bindingRepository.findByIdAndStatusForUpdate( 1L, IdentityBindingStatus.ACTIVE)) diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 36f3ccfd..1f8a7636 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -14,6 +14,7 @@ import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.identity.AccountLoginGuard; import com.iflytek.skillhub.auth.identity.PlatformPrincipalFactory; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; @@ -99,7 +100,7 @@ class LocalAuthServiceTest { given(role.getCode()).willReturn("USER_ADMIN"); UserRoleBinding binding = new UserRoleBinding("usr_1", role); - given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential)); given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true); given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of(binding)); @@ -116,7 +117,7 @@ class LocalAuthServiceTest { LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); - given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential)); given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); given(passwordEncoder.matches("bad", "encoded")).willReturn(false); @@ -135,7 +136,7 @@ class LocalAuthServiceTest { credential.setFailedAttempts(4); UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); - given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential)); given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); given(passwordEncoder.matches("bad", "encoded")).willReturn(false); @@ -153,7 +154,7 @@ class LocalAuthServiceTest { credential.setLockedUntil(Instant.now(CLOCK).plusSeconds(5 * 60)); UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); - given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential)); given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); assertThatThrownBy(() -> service.login("alice", "Abcd123!")) @@ -163,7 +164,7 @@ class LocalAuthServiceTest { @Test void login_withUnknownUsername_stillPerformsDummyPasswordCheck() { - given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty()); + given(credentialRepository.findByUsernameIgnoreCaseForUpdate("ghost")).willReturn(Optional.empty()); given(passwordEncoder.matches(eq("bad"), eq("$2a$12$8Q/2o2A0V.b18G2DutV4c.s5zZxH6MECM7tP8mYv6b6Q6x6o9v3vu"))) .willReturn(false); @@ -182,7 +183,7 @@ class LocalAuthServiceTest { UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); user.setStatus(UserStatus.DISABLED); - given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential)); given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); assertThatThrownBy(() -> service.login("alice", "Abcd123!")) @@ -196,7 +197,7 @@ class LocalAuthServiceTest { UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); user.setStatus(UserStatus.PENDING); - given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential)); given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); assertThatThrownBy(() -> service.login("alice", "Abcd123!")) @@ -210,7 +211,7 @@ class LocalAuthServiceTest { UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); user.setStatus(UserStatus.MERGED); - given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential)); given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); assertThatThrownBy(() -> service.login("alice", "Abcd123!")) @@ -225,7 +226,7 @@ class LocalAuthServiceTest { UserAccount user = UserAccount.systemAccount( "system_1", "system", null, null); - given(credentialRepository.findByUsernameIgnoreCase("system")) + given(credentialRepository.findByUsernameIgnoreCaseForUpdate("system")) .willReturn(Optional.of(credential)); given(userAccountRepository.findById("system_1")) .willReturn(Optional.of(user)); @@ -242,7 +243,7 @@ class LocalAuthServiceTest { LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); - given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(credentialRepository.findByUsernameIgnoreCaseForUpdate("alice")).willReturn(Optional.of(credential)); given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true); given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of()); @@ -252,6 +253,77 @@ class LocalAuthServiceTest { assertThat(principal.platformRoles()).containsExactly("USER"); } + @Test + void reauthenticate_withCurrentUsersPassword_returnsPrincipal() { + LocalCredential credential = + new LocalCredential("usr_1", "alice", "encoded"); + UserAccount user = + new UserAccount( + "usr_1", + "alice", + "alice@example.com", + null); + given(credentialRepository.findByUserIdForUpdate("usr_1")) + .willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")) + .willReturn(Optional.of(user)); + given(passwordEncoder.matches("Abcd123!", "encoded")) + .willReturn(true); + given(userRoleBindingRepository.findByUserId("usr_1")) + .willReturn(List.of()); + + PlatformPrincipal principal = service.reauthenticate( + "usr_1", + "Abcd123!"); + + assertThat(principal.userId()).isEqualTo("usr_1"); + assertThat(principal.oauthProvider()).isEqualTo("local"); + verify(credentialRepository).save(credential); + } + + @Test + void reauthenticate_withInvalidPassword_updatesLockCounters() { + LocalCredential credential = + new LocalCredential("usr_1", "alice", "encoded"); + credential.setFailedAttempts(4); + UserAccount user = + new UserAccount( + "usr_1", + "alice", + "alice@example.com", + null); + given(credentialRepository.findByUserIdForUpdate("usr_1")) + .willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")) + .willReturn(Optional.of(user)); + given(passwordEncoder.matches("bad", "encoded")) + .willReturn(false); + + assertThatThrownBy(() -> + service.reauthenticate("usr_1", "bad")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + assertThat(credential.getFailedAttempts()).isEqualTo(5); + assertThat(credential.getLockedUntil()) + .isEqualTo(Instant.now(CLOCK).plusSeconds(15 * 60)); + verify(credentialRepository).save(credential); + } + + @Test + void reauthenticate_withoutLocalCredential_doesNotCheckPassword() { + given(credentialRepository.findByUserIdForUpdate("oauth-only")) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> + service.reauthenticate("oauth-only", "secret")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.local.notEnabled"); + + verify(passwordEncoder, never()).matches(any(), any()); + } + @Test void changePassword_withoutLocalCredential_rejectsRequest() { given(credentialRepository.findByUserId("oauth-only")).willReturn(Optional.empty()); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilterTest.java index 0cf7ae02..054fd5ae 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilterTest.java @@ -11,6 +11,7 @@ import static org.mockito.Mockito.when; import com.iflytek.skillhub.auth.identity.IdentityCoreException; import com.iflytek.skillhub.auth.identity.IdentityFailureCode; import com.iflytek.skillhub.auth.identity.IdentityProviderReadinessService; +import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; import jakarta.servlet.FilterChain; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -25,6 +26,7 @@ class IdentityProviderRouteReadinessFilterTest { private ClientRegistrationRepository registrationRepository; private IdentityProviderReadinessService readinessService; private ClientRegistration registration; + private OAuth2LoginFailureHandler failureHandler; private IdentityProviderRouteReadinessFilter filter; @BeforeEach @@ -33,12 +35,15 @@ class IdentityProviderRouteReadinessFilterTest { ClientRegistrationRepository.class); readinessService = mock( IdentityProviderReadinessService.class); + failureHandler = mock( + OAuth2LoginFailureHandler.class); registration = registration(); when(registrationRepository.findByRegistrationId("github")) .thenReturn(registration); filter = new IdentityProviderRouteReadinessFilter( registrationRepository, - readinessService); + readinessService, + failureHandler); } @Test @@ -75,6 +80,32 @@ class IdentityProviderRouteReadinessFilterTest { verify(chain, never()).doFilter(request, response); } + @Test + void mismatchCallbackRedirectsOwnedIdentityLinkFlow() + throws Exception { + FilterChain chain = mock(FilterChain.class); + doThrow(new IdentityCoreException( + IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH)) + .when(readinessService).requireReady(registration); + MockHttpServletRequest request = request( + "/login/oauth2/code/github"); + MockHttpServletResponse response = + new MockHttpServletResponse(); + when(failureHandler.redirectIdentityLinkRouteFailure( + request, + response, + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE)) + .thenReturn(true); + + filter.doFilter(request, response, chain); + + verify(failureHandler).redirectIdentityLinkRouteFailure( + request, + response, + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); + verify(chain, never()).doFilter(request, response); + } + @Test void disabledAuthorizationRouteIsRejectedBeforeRedirect() throws Exception { 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 6cd0b316..160d9550 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 @@ -1,13 +1,25 @@ package com.iflytek.skillhub.auth.oauth; 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.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; import jakarta.servlet.http.HttpSession; +import java.util.List; +import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -15,6 +27,7 @@ import static org.mockito.Mockito.mock; class OAuth2AuthorizationRequestResolverTest { private SkillHubOAuth2AuthorizationRequestResolver resolver; + private OAuthLoginFlowService oauthLoginFlowService; @BeforeEach void setUp() { @@ -30,17 +43,64 @@ class OAuth2AuthorizationRequestResolverTest { .scope("read:user") .clientName("GitHub") .build(); - OAuthLoginFlowService oauthLoginFlowService = new OAuthLoginFlowService( + oauthLoginFlowService = new OAuthLoginFlowService( java.util.List.of(), mock(TrustedProviderRouteResolver.class), - mock(ExternalIdentityLoginService.class) + mock(ExternalIdentityLoginService.class), + mock(ExternalIdentityLinkService.class), + mock(IdentityLinkSessionManager.class) ); resolver = new SkillHubOAuth2AuthorizationRequestResolver( new InMemoryClientRegistrationRepository(github), - oauthLoginFlowService + oauthLoginFlowService, + mock(IdentityLinkSessionManager.class) ); } + @Test + void resolve_preservesReturnToAcrossCallbackUntilSuccessHandler() + throws Exception { + String returnTo = + "/settings/security?identityLink=linked" + + "&intentId=7d26c414-6040-48b5-b025-53a16b8aa6b9"; + MockHttpServletRequest authorizationRequest = + oauthRequest("/oauth2/authorization/github"); + authorizationRequest.setParameter("returnTo", returnTo); + + assertThat(resolver.resolve(authorizationRequest)).isNotNull(); + + MockHttpSession session = + (MockHttpSession) authorizationRequest.getSession(false); + assertThat(session).isNotNull(); + assertThat(session.getAttribute( + OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)) + .isEqualTo(returnTo); + + MockHttpServletRequest callbackRequest = + oauthRequest("/login/oauth2/code/github"); + callbackRequest.setSession(session); + assertThat(resolver.resolve(callbackRequest)).isNull(); + assertThat(session.getAttribute( + OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)) + .isEqualTo(returnTo); + + OAuth2LoginSuccessHandler successHandler = + new OAuth2LoginSuccessHandler( + new PlatformSessionService(), + oauthLoginFlowService); + MockHttpServletResponse response = + new MockHttpServletResponse(); + successHandler.onAuthenticationSuccess( + callbackRequest, + response, + oauthAuthentication()); + + assertThat(response.getRedirectedUrl()).isEqualTo(returnTo); + assertThat(session.getAttribute( + OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)) + .isNull(); + } + @Test void resolve_storesSanitizedReturnToInSession() { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); @@ -65,4 +125,41 @@ class OAuth2AuthorizationRequestResolverTest { assertThat(session).isNotNull(); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); } + + @Test + void resolve_nonAuthorizationRequestDoesNotCreateSession() { + MockHttpServletRequest request = + oauthRequest("/login/oauth2/code/github"); + + assertThat(resolver.resolve(request)).isNull(); + assertThat(request.getSession(false)).isNull(); + } + + private MockHttpServletRequest oauthRequest(String path) { + MockHttpServletRequest request = + new MockHttpServletRequest("GET", path); + request.setServletPath(path); + return request; + } + + private Authentication oauthAuthentication() { + PlatformPrincipal principal = new PlatformPrincipal( + "user-1", + "User", + "user@example.com", + null, + "github", + Set.of()); + return new UsernamePasswordAuthenticationToken( + new DefaultOAuth2User( + List.of(), + Map.of( + "platformPrincipal", + principal, + "login", + "user"), + "login"), + null, + List.of()); + } } 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 52c0077b..639bea0f 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 @@ -1,5 +1,7 @@ package com.iflytek.skillhub.auth.oauth; +import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager; +import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode; import jakarta.servlet.http.HttpSession; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; @@ -15,7 +17,9 @@ import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -104,7 +108,9 @@ class OAuth2LoginHandlersTest { @Test void failureHandler_redirectsBackToLoginWithReturnTo() throws Exception { OAuthLoginFlowService oauthLoginFlowService = mock(OAuthLoginFlowService.class); - OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(oauthLoginFlowService); + OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler( + oauthLoginFlowService, + mock(IdentityLinkSessionManager.class)); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); HttpSession session = request.getSession(true); @@ -130,4 +136,129 @@ class OAuth2LoginHandlersTest { assertThat(response.getRedirectedUrl()).isEqualTo("/login?returnTo=%2Fsettings%2Faccounts"); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); } + + @Test + void failureHandler_preservesIdentityLinkIntentForRetry() + throws Exception { + OAuthLoginFlowService oauthLoginFlowService = + mock(OAuthLoginFlowService.class); + IdentityLinkSessionManager sessionManager = + mock(IdentityLinkSessionManager.class); + OAuth2LoginFailureHandler handler = + new OAuth2LoginFailureHandler( + oauthLoginFlowService, + sessionManager); + MockHttpServletRequest request = + new MockHttpServletRequest(); + MockHttpServletResponse response = + new MockHttpServletResponse(); + HttpSession session = request.getSession(true); + UUID intentId = UUID.randomUUID(); + org.mockito.Mockito.when( + sessionManager.consumeFailedBrowserFlow(session)) + .thenReturn(Optional.of(intentId)); + + handler.onAuthenticationFailure( + request, + response, + new OAuth2AuthenticationException( + new OAuth2Error("access_denied"))); + + assertThat(response.getRedirectedUrl()) + .isEqualTo( + "/settings/security?identityLink=failed" + + "&intentId=" + + intentId + + "&reasonCode=" + + "PROVIDER_AUTHENTICATION_FAILED"); + org.mockito.Mockito.verify( + oauthLoginFlowService, + org.mockito.Mockito.never()) + .resolveFailureRedirect( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void failureHandler_preservesStableIdentityLinkReasonCode() + throws Exception { + OAuthLoginFlowService oauthLoginFlowService = + mock(OAuthLoginFlowService.class); + IdentityLinkSessionManager sessionManager = + mock(IdentityLinkSessionManager.class); + OAuth2LoginFailureHandler handler = + new OAuth2LoginFailureHandler( + oauthLoginFlowService, + sessionManager); + MockHttpServletRequest request = + new MockHttpServletRequest(); + MockHttpServletResponse response = + new MockHttpServletResponse(); + HttpSession session = request.getSession(true); + UUID intentId = UUID.randomUUID(); + OAuth2AuthenticationException failure = + new OAuth2AuthenticationException( + new OAuth2Error( + "identity_link_failed", + "PROVIDER_UNAVAILABLE", + null)); + org.mockito.Mockito.when( + sessionManager.consumeFailedBrowserFlow(session)) + .thenReturn(Optional.of(intentId)); + org.mockito.Mockito.when( + oauthLoginFlowService + .identityLinkFailureReasonCode(failure)) + .thenReturn(Optional.of( + "PROVIDER_UNAVAILABLE")); + + handler.onAuthenticationFailure( + request, + response, + failure); + + assertThat(response.getRedirectedUrl()) + .isEqualTo( + "/settings/security?identityLink=failed" + + "&intentId=" + + intentId + + "&reasonCode=PROVIDER_UNAVAILABLE"); + } + + @Test + void routeFailureRedirectsOnlyAnOwnedIdentityLinkFlow() + throws Exception { + OAuthLoginFlowService oauthLoginFlowService = + mock(OAuthLoginFlowService.class); + IdentityLinkSessionManager sessionManager = + mock(IdentityLinkSessionManager.class); + OAuth2LoginFailureHandler handler = + new OAuth2LoginFailureHandler( + oauthLoginFlowService, + sessionManager); + MockHttpServletRequest request = + new MockHttpServletRequest(); + MockHttpServletResponse response = + new MockHttpServletResponse(); + HttpSession session = request.getSession(true); + UUID intentId = UUID.randomUUID(); + org.mockito.Mockito.when( + sessionManager.consumeFailedBrowserFlow(session)) + .thenReturn(Optional.of(intentId)); + + boolean redirected = + handler.redirectIdentityLinkRouteFailure( + request, + response, + IdentityLinkFailureCode.PROVIDER_UNAVAILABLE); + + assertThat(redirected).isTrue(); + assertThat(response.getRedirectedUrl()) + .isEqualTo( + "/settings/security?identityLink=failed" + + "&intentId=" + + intentId + + "&reasonCode=PROVIDER_UNAVAILABLE"); + org.mockito.Mockito.verify(oauthLoginFlowService) + .consumeReturnTo(session); + } } 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 e65a5206..948b58e6 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 @@ -13,10 +13,16 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService; +import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService; import com.iflytek.skillhub.auth.identity.IdentityCoreException; import com.iflytek.skillhub.auth.identity.IdentityFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityLinkActor; +import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserFlow; +import com.iflytek.skillhub.auth.identity.IdentityLinkBrowserPhase; +import com.iflytek.skillhub.auth.identity.IdentityLinkOutcome; import com.iflytek.skillhub.auth.identity.IdentityLoginContext; import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome; +import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager; import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle; @@ -29,6 +35,9 @@ import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.mockito.InOrder; import org.springframework.mock.web.MockHttpServletRequest; @@ -39,9 +48,16 @@ import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; class OAuthLoginFlowServiceTest { + @AfterEach + void resetRequestContext() { + RequestContextHolder.resetRequestAttributes(); + } + @Test void resolvesReadyRouteBeforeOAuthUpstreamAndAdapterCalls() { OAuthClaimsExtractor extractor = mock(OAuthClaimsExtractor.class); @@ -74,6 +90,8 @@ class OAuthLoginFlowServiceTest { List.of(extractor), resolver, identityLoginService, + mock(ExternalIdentityLinkService.class), + mock(IdentityLinkSessionManager.class), delegate); clearInvocations(extractor); @@ -114,6 +132,8 @@ class OAuthLoginFlowServiceTest { List.of(extractor), resolver, identityLoginService, + mock(ExternalIdentityLinkService.class), + mock(IdentityLinkSessionManager.class), delegate); clearInvocations(extractor); @@ -139,7 +159,9 @@ class OAuthLoginFlowServiceTest { new OAuthLoginFlowService( List.of(), resolver, - identityLoginService); + identityLoginService, + mock(ExternalIdentityLinkService.class), + mock(IdentityLinkSessionManager.class)); PlatformPrincipal principal = principal(); when(identityLoginService.authenticate(any(), any(), any())) .thenReturn(new IdentityLoginOutcome.Authenticated( @@ -170,7 +192,9 @@ class OAuthLoginFlowServiceTest { new OAuthLoginFlowService( List.of(), resolver, - identityLoginService); + identityLoginService, + mock(ExternalIdentityLinkService.class), + mock(IdentityLinkSessionManager.class)); when(identityLoginService.authenticate(any(), any(), any())) .thenReturn(new IdentityLoginOutcome.PendingApproval( "ACCOUNT_PENDING")); @@ -193,7 +217,9 @@ class OAuthLoginFlowServiceTest { new OAuthLoginFlowService( List.of(), resolver, - identityLoginService); + identityLoginService, + mock(ExternalIdentityLinkService.class), + mock(IdentityLinkSessionManager.class)); when(identityLoginService.authenticate(any(), any(), any())) .thenReturn(new IdentityLoginOutcome.LinkRequired( "EMAIL_COLLISION")); @@ -228,7 +254,9 @@ class OAuthLoginFlowServiceTest { new OAuthLoginFlowService( List.of(), resolver, - identityLoginService); + identityLoginService, + mock(ExternalIdentityLinkService.class), + mock(IdentityLinkSessionManager.class)); when(identityLoginService.authenticate(any(), any(), any())) .thenThrow(new IdentityCoreException( IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH)); @@ -246,6 +274,68 @@ class OAuthLoginFlowServiceTest { "provider_authority_mismatch")); } + @Test + void browserLinkFlowDoesNotRunNormalLoginOrReplacePrimaryAccount() { + TrustedProviderRouteResolver resolver = + mock(TrustedProviderRouteResolver.class); + ExternalIdentityLoginService identityLoginService = + mock(ExternalIdentityLoginService.class); + ExternalIdentityLinkService identityLinkService = + mock(ExternalIdentityLinkService.class); + IdentityLinkSessionManager sessionManager = + mock(IdentityLinkSessionManager.class); + OAuthLoginFlowService service = + new OAuthLoginFlowService( + List.of(), + resolver, + identityLoginService, + identityLinkService, + sessionManager); + ResolvedProviderHandle provider = + ResolvedProviderHandleTestFixture.handle("github"); + UUID intentId = UUID.randomUUID(); + IdentityLinkActor actor = new IdentityLinkActor( + "usr_1", + "local", + "high-entropy-session-nonce", + context()); + MockHttpServletRequest request = + new MockHttpServletRequest( + "GET", + "/login/oauth2/code/github"); + request.getSession(true); + RequestContextHolder.setRequestAttributes( + new ServletRequestAttributes(request)); + when(sessionManager.consumeBrowserFlow( + request, + "github", + context())) + .thenReturn(Optional.of( + new IdentityLinkBrowserFlow( + intentId, + IdentityLinkBrowserPhase.LINK, + actor))); + when(identityLinkService.link( + actor, + intentId, + provider, + result())) + .thenReturn(new IdentityLinkOutcome.Linked( + principal(), + 42L)); + + PlatformPrincipal linked = service.authenticate( + provider, + result(), + context()); + + assertThat(linked.userId()).isEqualTo("usr_1"); + verifyNoInteractions(identityLoginService); + verify(sessionManager).remove( + request.getSession(false), + intentId); + } + @Test void rememberReturnToStoresSanitizedReturnTarget() { OAuthLoginFlowService service = service(); @@ -273,6 +363,46 @@ class OAuthLoginFlowServiceTest { assertThat(redirect).isEqualTo("/access-denied"); } + @Test + void identityLinkFailureRedirectPreservesResumableIntent() { + UUID intentId = UUID.randomUUID(); + + String redirect = service().resolveFailureRedirect( + new OAuth2AuthenticationException( + new OAuth2Error("identity_link_failed")), + "/settings/security?identityLink=linked" + + "&intentId=" + + intentId); + + assertThat(redirect) + .isEqualTo( + "/settings/security?identityLink=failed" + + "&intentId=" + + intentId); + } + + @Test + void identityLinkFailureRedirectIncludesStableReasonCode() { + UUID intentId = UUID.randomUUID(); + + String redirect = service().resolveFailureRedirect( + new OAuth2AuthenticationException( + new OAuth2Error( + "identity_link_failed", + "PROVIDER_UNAVAILABLE", + null)), + "/settings/security?identityLink=linked" + + "&intentId=" + + intentId); + + assertThat(redirect) + .isEqualTo( + "/settings/security?identityLink=failed" + + "&intentId=" + + intentId + + "&reasonCode=PROVIDER_UNAVAILABLE"); + } + @Test void resolveFailureRedirectMapsMergedAccountToAccessDenied() { assertThat(service().resolveFailureRedirect( @@ -308,7 +438,9 @@ class OAuthLoginFlowServiceTest { return new OAuthLoginFlowService( List.of(), mock(TrustedProviderRouteResolver.class), - mock(ExternalIdentityLoginService.class)); + mock(ExternalIdentityLoginService.class), + mock(ExternalIdentityLinkService.class), + mock(IdentityLinkSessionManager.class)); } private static ProviderAuthenticationResult result() { diff --git a/web/e2e/settings-security-capability.spec.ts b/web/e2e/settings-security-capability.spec.ts index b2d45910..e579b6b2 100644 --- a/web/e2e/settings-security-capability.spec.ts +++ b/web/e2e/settings-security-capability.spec.ts @@ -1,7 +1,7 @@ import { expect, test, type Page } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' import { csrfHeaders } from './helpers/csrf' -import { loginWithCredentials } from './helpers/session' +import { createFreshSession, loginWithCredentials } from './helpers/session' function getOptionalEnv(name: string): string | undefined { const value = process.env[name]?.trim() @@ -15,6 +15,20 @@ function adminCredentials() { } } +function gitLabIdentityLinkE2EEnabled(): boolean { + return getOptionalEnv('E2E_IDENTITY_LINK_BROWSER_PROVIDER') === 'gitlab' +} + +function requireGitLabIdentityLinkE2E(): void { + const enabled = gitLabIdentityLinkE2EEnabled() + if (!enabled && process.env.CI) { + throw new Error( + 'E2E_IDENTITY_LINK_BROWSER_PROVIDER=gitlab is required in CI', + ) + } + test.skip(!enabled, 'requires the dedicated GitLab identity-link test provider') +} + async function currentDisplayName(page: Page, headers?: Record): Promise { const response = await page.context().request.get('/api/v1/auth/me', { headers }) expect(response.ok()).toBeTruthy() @@ -23,7 +37,9 @@ async function currentDisplayName(page: Page, headers?: Record): } test.describe('Security Settings capability (Real API)', () => { - test.use({ baseURL: 'http://127.0.0.1:3000' }) + test.use({ + baseURL: getOptionalEnv('E2E_BASE_URL') ?? 'http://127.0.0.1:3000', + }) test('shows the security menu entry and password form for local admin accounts', async ({ page }, testInfo) => { await setEnglishLocale(page) @@ -32,6 +48,8 @@ test.describe('Security Settings capability (Real API)', () => { await page.goto('/settings/security') await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Login Methods', exact: true })).toBeVisible() + await expect(page.getByText('Local password', { exact: true }).first()).toBeVisible() await expect(page.getByLabel('Current Password')).toBeVisible() await expect(page.getByLabel('New Password')).toBeVisible() @@ -39,6 +57,125 @@ test.describe('Security Settings capability (Real API)', () => { await expect(page.getByRole('link', { name: 'Security Settings' })).toBeVisible() }) + test('requires fresh local reauthentication before linking an external provider', async ({ page }, testInfo) => { + requireGitLabIdentityLinkE2E() + await setEnglishLocale(page) + const credentials = await createFreshSession(page, testInfo) + + await page.goto('/settings/security') + await expect(page.getByRole('heading', { name: 'Login Methods', exact: true })).toBeVisible() + const addButton = page.getByRole('button', { name: 'Add' }).first() + await expect(addButton).toBeVisible() + await addButton.click() + + const dialog = page.getByRole('dialog', { name: 'Verify account control' }) + await expect(dialog).toBeVisible() + await dialog.getByLabel('Local password').fill(credentials.password) + await dialog.getByRole('button', { name: 'Verify password' }).click() + + await expect(dialog.getByText( + 'Current account verified. Now authenticate the login method you want to link.', + )).toBeVisible() + await expect(dialog.getByRole('button', { name: /^Continue with / })).toBeVisible() + + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).toBeHidden() + }) + + test('links, unlinks, and relinks a browser identity through the deployed stack', async ({ page }, testInfo) => { + requireGitLabIdentityLinkE2E() + await setEnglishLocale(page) + const credentials = await createFreshSession(page, testInfo) + await page.goto('/settings/security') + + const availableMethods = page.locator( + 'section[aria-labelledby="available-login-methods"]', + ) + const linkedMethods = page.locator( + 'section[aria-labelledby="linked-login-methods"]', + ) + + async function linkGitLab() { + await expect(availableMethods.getByText('GitLab', { exact: true })).toBeVisible() + await availableMethods.getByRole('button', { name: 'Add' }).click() + const dialog = page.getByRole('dialog', { name: 'Verify account control' }) + await dialog.getByLabel('Local password').fill(credentials.password) + await dialog.getByRole('button', { name: 'Verify password' }).click() + await expect(dialog.getByRole('button', { name: 'Continue with GitLab' })).toBeVisible() + await dialog.getByRole('button', { name: 'Continue with GitLab' }).click() + await page.waitForURL(/identityLink=linked/) + await expect(page.getByText('The login method was linked successfully.')).toBeVisible() + await expect(linkedMethods.getByText('GitLab', { exact: true })).toBeVisible() + } + + await linkGitLab() + + await linkedMethods.getByRole('button', { name: 'Remove' }).click() + const unlinkDialog = page.getByRole('dialog', { name: 'Verify account control' }) + await unlinkDialog.getByLabel('Local password').fill(credentials.password) + await unlinkDialog.getByRole('button', { name: 'Verify password' }).click() + await expect( + unlinkDialog.getByRole('button', { name: 'Remove login method' }), + ).toBeVisible() + await unlinkDialog.getByRole('button', { name: 'Remove login method' }).click() + await expect(unlinkDialog).toBeHidden() + await expect(availableMethods.getByText('GitLab', { exact: true })).toBeVisible() + + await linkGitLab() + }) + + test('redirects an unavailable identity-link provider with a stable reason code', async ({ page }, testInfo) => { + requireGitLabIdentityLinkE2E() + await setEnglishLocale(page) + const credentials = await createFreshSession(page, testInfo) + const request = page.context().request + + const createIntent = await request.post('/api/v1/auth/identity-link-intents/link', { + data: { providerCode: 'gitlab' }, + headers: await csrfHeaders(page), + }) + expect(createIntent.ok()).toBeTruthy() + const createBody = await createIntent.json() as { data: { id: string } } + const intentId = createBody.data.id + + const reauthenticate = await request.post( + `/api/v1/auth/identity-link-intents/${intentId}/reauthenticate/local`, + { + data: { password: credentials.password }, + headers: await csrfHeaders(page), + }, + ) + expect(reauthenticate.ok()).toBeTruthy() + + const prepareLink = await request.post( + `/api/v1/auth/identity-link-intents/${intentId}/link/browser`, + { headers: await csrfHeaders(page) }, + ) + expect(prepareLink.ok()).toBeTruthy() + const prepareBody = await prepareLink.json() as { data: { actionUrl: string } } + const unavailableActionUrl = prepareBody.data.actionUrl.replace( + '/oauth2/authorization/gitlab', + '/oauth2/authorization/missing-provider', + ) + expect(unavailableActionUrl).not.toBe(prepareBody.data.actionUrl) + + const failure = await request.get(unavailableActionUrl, { maxRedirects: 0 }) + expect(failure.status()).toBe(302) + const location = failure.headers().location + expect(location).toBeTruthy() + const redirect = new URL(location, 'http://127.0.0.1') + expect(redirect.pathname).toBe('/settings/security') + expect(redirect.searchParams.get('identityLink')).toBe('failed') + expect(redirect.searchParams.get('intentId')).toBe(intentId) + expect(redirect.searchParams.get('reasonCode')).toBe('PROVIDER_UNAVAILABLE') + + const ordinaryOAuth = await request.get( + '/oauth2/authorization/missing-provider', + { maxRedirects: 0 }, + ) + expect(ordinaryOAuth.status()).toBe(403) + }) + test('hides the security menu entry and rejects password changes without a local credential', async ({ page }) => { await setEnglishLocale(page) await page.context().setExtraHTTPHeaders({ diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 36a60ebf..71299124 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -29,10 +29,12 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, ], - webServer: { - command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort', - url: 'http://127.0.0.1:3000', - reuseExistingServer: true, - timeout: 120000, - }, + webServer: process.env.E2E_BASE_URL + ? undefined + : { + command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort', + url: 'http://127.0.0.1:3000', + reuseExistingServer: true, + timeout: 120000, + }, }) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index f2d4287b..5b7fd3eb 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -24,11 +24,19 @@ vi.mock('@/shared/lib/api-error', () => ({ status: number serverMessage?: string serverMessageKey?: string - constructor(message: string, status: number, serverMessage?: string, serverMessageKey?: string) { + reasonCode?: string + constructor( + message: string, + status: number, + serverMessage?: string, + serverMessageKey?: string, + reasonCode?: string, + ) { super(message) this.status = status this.serverMessage = serverMessage this.serverMessageKey = serverMessageKey + this.reasonCode = reasonCode } }, handleApiError: vi.fn(), @@ -40,6 +48,7 @@ import { fetchText, getDirectAuthRuntimeConfig, getSessionBootstrapRuntimeConfig, + identityLinkApi, namespaceApi, } from './client' @@ -163,6 +172,131 @@ describe('namespaceApi.delete', () => { }) }) +describe('identityLinkApi', () => { + it('normalizes the login-method account state', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ + code: 0, + msg: 'ok', + data: { + localPasswordEnabled: true, + linkedProviders: [{ + bindingId: 41, + providerCode: 'github', + displayName: 'GitHub', + methodTypes: ['OAUTH_REDIRECT'], + usable: true, + canUnlink: true, + }], + availableProviders: [{ + providerCode: 'oidc', + displayName: 'Company OIDC', + methodTypes: ['OAUTH_REDIRECT'], + }], + }, + timestamp: '2026-07-31T00:00:00Z', + requestId: 'req-identity-link', + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(identityLinkApi.getAccountState()).resolves.toEqual({ + localPasswordEnabled: true, + linkedProviders: [{ + bindingId: 41, + providerCode: 'github', + displayName: 'GitHub', + methodTypes: ['OAUTH_REDIRECT'], + usable: true, + canUnlink: true, + }], + availableProviders: [{ + providerCode: 'oidc', + displayName: 'Company OIDC', + methodTypes: ['OAUTH_REDIRECT'], + }], + }) + }) + + it('creates a session-bound link intent with CSRF protection', async () => { + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + cookie: 'XSRF-TOKEN=identity-link-csrf', + }, + }) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ + code: 0, + msg: 'ok', + data: { + id: 'a0b89f51-a892-4b73-bdac-63df2cb14691', + operation: 'LINK', + status: 'PENDING_REAUTHENTICATION', + providerCode: 'github', + expiresAt: '2026-07-31T00:10:00Z', + }, + timestamp: '2026-07-31T00:00:00Z', + requestId: 'req-identity-link', + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const intent = await identityLinkApi.createLinkIntent('github') + + expect(intent.status).toBe('PENDING_REAUTHENTICATION') + const request = fetchMock.mock.calls[0]?.[0] as Request + expect(request.url).toBe( + 'http://localhost/api/v1/auth/identity-link-intents/link', + ) + expect(request.method).toBe('POST') + await expect(request.clone().json()).resolves.toEqual({ + providerCode: 'github', + }) + expect(request.headers.get('X-XSRF-TOKEN')) + .toBe('identity-link-csrf') + }) + + it('preserves stable identity-link failure reason codes', async () => { + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + cookie: 'XSRF-TOKEN=identity-link-csrf', + }, + }) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ + code: 409, + msg: 'Keep another login method.', + reasonCode: 'FINAL_LOGIN_METHOD', + timestamp: '2026-07-31T00:00:00Z', + requestId: 'req-identity-link-error', + }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + await expect( + identityLinkApi.completeUnlink( + 'a0b89f51-a892-4b73-bdac-63df2cb14691', + ), + ).rejects.toMatchObject({ + status: 409, + reasonCode: 'FINAL_LOGIN_METHOD', + }) + }) +}) + 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 d701fd11..3e843ab8 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,5 +1,5 @@ import createClient from 'openapi-fetch' -import type { paths } from './generated/schema' +import type { components, paths } from './generated/schema' import type { ChangePasswordRequest, PasswordResetConfirmRequest, @@ -29,6 +29,12 @@ import type { PagedResponse, ReportDisposition, AuthMethod, + IdentityLinkAccountState, + IdentityLinkBinding, + IdentityLinkCredentialRequest, + IdentityLinkIntent, + IdentityLinkProvider, + IdentityProviderLoginMethodType, OAuthProvider, User, ManagedNamespace, @@ -85,6 +91,22 @@ function getApiBaseUrl(): string { return getRuntimeConfig().apiBaseUrl ?? '' } +function getOpenApiBaseUrl(): string { + const configured = getApiBaseUrl() + if (/^https?:\/\//i.test(configured)) { + return configured + } + if ( + typeof window !== 'undefined' + && typeof window.location?.origin === 'string' + ) { + return configured + ? prependApiBaseUrl(window.location.origin, configured) + : window.location.origin + } + return configured || 'http://localhost' +} + function parseBooleanFlag(value: string | undefined): boolean { if (!value) { return false @@ -92,7 +114,10 @@ function parseBooleanFlag(value: string | undefined): boolean { return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()) } -const client = createClient({ baseUrl: getApiBaseUrl() }) +const client = createClient({ + baseUrl: getOpenApiBaseUrl(), + fetch: (request) => globalThis.fetch(request), +}) function getCsrfToken(): string | null { const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/) @@ -170,6 +195,7 @@ type ApiEnvelope = { code: number msg: string data: T + reasonCode?: string timestamp: string requestId: string } @@ -415,6 +441,352 @@ export const authApi = { }, } +type IdentityLinkIntentSchema = components['schemas']['IdentityLinkIntentResponse'] +type IdentityLinkAccountStateSchema = + components['schemas']['IdentityLinkAccountStateResponse'] +type IdentityLinkBindingSchema = components['schemas']['IdentityLinkBindingResponse'] +type IdentityLinkProviderSchema = components['schemas']['IdentityLinkProviderResponse'] +type IdentityLinkBrowserStartSchema = + components['schemas']['IdentityLinkBrowserStartResponse'] +type IdentityLinkErrorSchema = + components['schemas']['IdentityLinkErrorResponse'] + +function normalizeIdentityLinkMethodTypes( + methodTypes: IdentityLinkBindingSchema['methodTypes'] + | IdentityLinkProviderSchema['methodTypes'], +): IdentityProviderLoginMethodType[] { + return methodTypes ? [...methodTypes] : [] +} + +function normalizeIdentityLinkBinding( + binding: IdentityLinkBindingSchema, +): IdentityLinkBinding { + if ( + binding.bindingId === undefined + || !binding.providerCode + || !binding.displayName + || binding.usable === undefined + || binding.canUnlink === undefined + ) { + throw new ApiError('apiError.invalidResponse', 500) + } + return { + ...binding, + bindingId: binding.bindingId, + providerCode: binding.providerCode, + displayName: binding.displayName, + methodTypes: normalizeIdentityLinkMethodTypes(binding.methodTypes), + usable: binding.usable, + canUnlink: binding.canUnlink, + } +} + +function normalizeIdentityLinkProvider( + provider: IdentityLinkProviderSchema, +): IdentityLinkProvider { + if (!provider.providerCode || !provider.displayName) { + throw new ApiError('apiError.invalidResponse', 500) + } + return { + ...provider, + providerCode: provider.providerCode, + displayName: provider.displayName, + methodTypes: normalizeIdentityLinkMethodTypes(provider.methodTypes), + } +} + +function normalizeIdentityLinkIntent( + intent: IdentityLinkIntentSchema, +): IdentityLinkIntent { + if ( + !intent.id + || !intent.operation + || !intent.status + || !intent.providerCode + || !intent.expiresAt + ) { + throw new ApiError('apiError.invalidResponse', 500) + } + return { + ...intent, + id: intent.id, + operation: intent.operation, + status: intent.status, + providerCode: intent.providerCode, + targetBindingId: intent.targetBindingId, + expiresAt: intent.expiresAt, + } +} + +function normalizeIdentityLinkAccountState( + state: IdentityLinkAccountStateSchema, +): IdentityLinkAccountState { + return { + localPasswordEnabled: state.localPasswordEnabled === true, + linkedProviders: (state.linkedProviders ?? []).map( + normalizeIdentityLinkBinding, + ), + availableProviders: (state.availableProviders ?? []).map( + normalizeIdentityLinkProvider, + ), + } +} + +function requireIdentityLinkActionUrl( + response: IdentityLinkBrowserStartSchema, +): string { + if (!response.actionUrl) { + throw new ApiError('apiError.invalidResponse', 500) + } + return response.actionUrl +} + +type GeneratedApiEnvelope = { + code?: number + msg?: string + data?: T +} + +type OpenApiEnvelopeResult = { + data?: GeneratedApiEnvelope + error?: unknown + response: Response +} + +function isApiFailureEnvelope( + value: unknown, +): value is IdentityLinkErrorSchema { + return typeof value === 'object' + && value !== null + && ('msg' in value || 'reasonCode' in value) +} + +function unwrapOpenApiEnvelope( + result: OpenApiEnvelopeResult, +): T { + const failure = isApiFailureEnvelope(result.error) + ? result.error + : undefined + const envelope = result.data + if ( + !result.response.ok + || result.error !== undefined + || envelope?.code !== 0 + || envelope?.data === undefined + ) { + const message = failure?.msg + || envelope?.msg + || `HTTP ${result.response.status}` + throw new ApiError( + message, + result.response.status, + failure?.msg, + failure?.msg, + failure?.reasonCode, + ) + } + return envelope.data +} + +export const identityLinkApi = { + async getAccountState(): Promise { + const result = await client.GET( + '/api/v1/auth/identity-links', + { + headers: withRequestHeaders(), + }, + ) + return normalizeIdentityLinkAccountState( + unwrapOpenApiEnvelope( + result, + ), + ) + }, + + async getIntent(intentId: string): Promise { + const result = await client.GET( + '/api/v1/auth/identity-link-intents/{intentId}', + { + params: { + path: { intentId }, + }, + headers: withRequestHeaders(), + }, + ) + return normalizeIdentityLinkIntent( + unwrapOpenApiEnvelope( + result, + ), + ) + }, + + async createLinkIntent(providerCode: string): Promise { + const result = await client.POST( + '/api/v1/auth/identity-link-intents/link', + { + headers: await ensureCsrfHeaders(), + body: { providerCode }, + }, + ) + return normalizeIdentityLinkIntent( + unwrapOpenApiEnvelope( + result, + ), + ) + }, + + async createUnlinkIntent(bindingId: number): Promise { + const result = await client.POST( + '/api/v1/auth/identity-link-intents/unlink', + { + headers: await ensureCsrfHeaders(), + body: { bindingId }, + }, + ) + return normalizeIdentityLinkIntent( + unwrapOpenApiEnvelope( + result, + ), + ) + }, + + async cancel(intentId: string): Promise { + const result = await client.DELETE( + '/api/v1/auth/identity-link-intents/{intentId}', + { + params: { + path: { intentId }, + }, + headers: await ensureCsrfHeaders(), + }, + ) + return normalizeIdentityLinkIntent( + unwrapOpenApiEnvelope( + result, + ), + ) + }, + + async reauthenticateLocal( + intentId: string, + password: string, + ): Promise { + const result = await client.POST( + '/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/local', + { + params: { + path: { intentId }, + }, + headers: await ensureCsrfHeaders(), + body: { password }, + }, + ) + return normalizeIdentityLinkIntent( + unwrapOpenApiEnvelope( + result, + ), + ) + }, + + async prepareBrowserReauthentication( + intentId: string, + providerCode: string, + ): Promise { + const result = await client.POST( + '/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/browser', + { + params: { + path: { intentId }, + }, + headers: await ensureCsrfHeaders(), + body: { providerCode }, + }, + ) + return requireIdentityLinkActionUrl( + unwrapOpenApiEnvelope( + result, + ), + ) + }, + + async reauthenticateCredential( + intentId: string, + providerCode: string, + credentials: IdentityLinkCredentialRequest, + ): Promise { + const result = await client.POST( + '/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/credential', + { + params: { + path: { intentId }, + }, + headers: await ensureCsrfHeaders(), + body: { providerCode, ...credentials }, + }, + ) + return normalizeIdentityLinkIntent( + unwrapOpenApiEnvelope( + result, + ), + ) + }, + + async prepareBrowserLink(intentId: string): Promise { + const result = await client.POST( + '/api/v1/auth/identity-link-intents/{intentId}/link/browser', + { + params: { + path: { intentId }, + }, + headers: await ensureCsrfHeaders(), + }, + ) + return requireIdentityLinkActionUrl( + unwrapOpenApiEnvelope( + result, + ), + ) + }, + + async linkCredential( + intentId: string, + credentials: IdentityLinkCredentialRequest, + ): Promise { + const result = await client.POST( + '/api/v1/auth/identity-link-intents/{intentId}/link/credential', + { + params: { + path: { intentId }, + }, + headers: await ensureCsrfHeaders(), + body: credentials, + }, + ) + return normalizeIdentityLinkIntent( + unwrapOpenApiEnvelope( + result, + ), + ) + }, + + async completeUnlink(intentId: string): Promise { + const result = await client.POST( + '/api/v1/auth/identity-link-intents/{intentId}/unlink', + { + params: { + path: { intentId }, + }, + headers: await ensureCsrfHeaders(), + }, + ) + return normalizeIdentityLinkIntent( + unwrapOpenApiEnvelope( + result, + ), + ) + }, +} + export const accountApi = { async initiateMerge(request: MergeInitiateRequest): Promise { return fetchJson('/api/v1/account/merge/initiate', { diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 706fe894..22773753 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1364,6 +1364,142 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/auth/identity-link-intents/{intentId}/unlink": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Complete unlink after fresh reauthentication */ + post: operations["completeUnlink"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/local": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Freshly reauthenticate with the local password */ + post: operations["reauthenticateLocal"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/credential": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Freshly reauthenticate with a credential provider */ + post: operations["reauthenticateCredential"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/identity-link-intents/{intentId}/reauthenticate/browser": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Start browser-provider fresh reauthentication */ + post: operations["prepareBrowserReauthentication"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/identity-link-intents/{intentId}/link/credential": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Authenticate and link a credential-provider identity */ + post: operations["linkCredential"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/identity-link-intents/{intentId}/link/browser": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Start browser authentication for the target identity */ + post: operations["prepareBrowserLink"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/identity-link-intents/unlink": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create an external identity unlink intent */ + post: operations["createUnlinkIntent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/identity-link-intents/link": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create an external identity link intent */ + post: operations["createLinkIntent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/auth/direct/login": { parameters: { query?: never; @@ -3064,6 +3200,44 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/auth/identity-links": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List linked and available login methods + * @description Returns active external bindings and providers that can be linked. + */ + get: operations["accountState"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/identity-link-intents/{intentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get an identity link intent */ + get: operations["getIntent"]; + put?: never; + post?: never; + /** Cancel an identity link intent */ + delete: operations["cancel"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/users": { parameters: { query?: never; @@ -3900,6 +4074,72 @@ export interface components { currentPassword: string; newPassword: string; }; + IdentityLinkErrorResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + /** @enum {string} */ + reasonCode: "INTENT_NOT_FOUND" | "REAUTHENTICATION_REQUIRED" | "SESSION_MISMATCH" | "INTENT_EXPIRED" | "ALREADY_CONSUMED" | "ACTIVE_INTENT_EXISTS" | "ACCOUNT_NOT_ELIGIBLE" | "PROVIDER_UNAVAILABLE" | "PROVIDER_AUTHENTICATION_FAILED" | "ALREADY_LINKED" | "IDENTITY_IN_USE" | "FINAL_LOGIN_METHOD" | "INVALID_OPERATION"; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseIdentityLinkIntentResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["IdentityLinkIntentResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + IdentityLinkIntentResponse: { + /** Format: uuid */ + id?: string; + /** @enum {string} */ + operation?: "LINK" | "UNLINK"; + /** @enum {string} */ + status?: "PENDING_REAUTHENTICATION" | "READY" | "COMPLETED" | "EXPIRED" | "CANCELLED"; + providerCode?: string; + /** Format: int64 */ + targetBindingId?: number; + /** Format: date-time */ + expiresAt?: string; + }; + IdentityLinkLocalReauthenticationRequest: { + password: string; + }; + IdentityLinkCredentialRequest: { + providerCode: string; + username: string; + password: string; + }; + IdentityLinkBrowserStartRequest: { + providerCode: string; + }; + ApiResponseIdentityLinkBrowserStartResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["IdentityLinkBrowserStartResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + IdentityLinkBrowserStartResponse: { + actionUrl?: string; + }; + IdentityLinkTargetCredentialRequest: { + username: string; + password: string; + }; + CreateIdentityUnlinkRequest: { + /** Format: int64 */ + bindingId: number; + }; + CreateIdentityLinkRequest: { + providerCode: string; + }; DirectLoginRequest: { provider: string; username: string; @@ -4886,6 +5126,34 @@ export interface components { displayName?: string; actionUrl?: string; }; + ApiResponseIdentityLinkAccountStateResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["IdentityLinkAccountStateResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + IdentityLinkAccountStateResponse: { + localPasswordEnabled?: boolean; + linkedProviders?: components["schemas"]["IdentityLinkBindingResponse"][]; + availableProviders?: components["schemas"]["IdentityLinkProviderResponse"][]; + }; + IdentityLinkBindingResponse: { + /** Format: int64 */ + bindingId?: number; + providerCode?: string; + displayName?: string; + methodTypes?: ("OAUTH_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[]; + usable?: boolean; + canUnlink?: boolean; + }; + IdentityLinkProviderResponse: { + providerCode?: string; + displayName?: string; + methodTypes?: ("OAUTH_REDIRECT" | "DIRECT_PASSWORD" | "SESSION_BOOTSTRAP")[]; + }; AdminUserSummaryResponse: { id?: string; username?: string; @@ -7966,6 +8234,706 @@ export interface operations { }; }; }; + completeUnlink: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"]; + }; + }; + /** @description Invalid identity link operation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Fresh reauthentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent belongs to another session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity Link intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity conflict, consumed intent, or final login method */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + }; + }; + reauthenticateLocal: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IdentityLinkLocalReauthenticationRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"]; + }; + }; + /** @description Invalid identity link operation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Fresh reauthentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent belongs to another session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity Link intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity conflict, consumed intent, or final login method */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + }; + }; + reauthenticateCredential: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IdentityLinkCredentialRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"]; + }; + }; + /** @description Invalid identity link operation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Fresh reauthentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent belongs to another session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity Link intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity conflict, consumed intent, or final login method */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + }; + }; + prepareBrowserReauthentication: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IdentityLinkBrowserStartRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityLinkBrowserStartResponse"]; + }; + }; + /** @description Invalid identity link operation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Fresh reauthentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent belongs to another session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity Link intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity conflict, consumed intent, or final login method */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + }; + }; + linkCredential: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IdentityLinkTargetCredentialRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"]; + }; + }; + /** @description Invalid identity link operation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Fresh reauthentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent belongs to another session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity Link intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity conflict, consumed intent, or final login method */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + }; + }; + prepareBrowserLink: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityLinkBrowserStartResponse"]; + }; + }; + /** @description Invalid identity link operation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Fresh reauthentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent belongs to another session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity Link intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity conflict, consumed intent, or final login method */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + }; + }; + createUnlinkIntent: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateIdentityUnlinkRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"]; + }; + }; + /** @description Invalid identity link operation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Fresh reauthentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent belongs to another session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity Link intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity conflict, consumed intent, or final login method */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + }; + }; + createLinkIntent: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateIdentityLinkRequest"]; + }; + }; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"]; + }; + }; + /** @description Invalid identity link operation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Fresh reauthentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent belongs to another session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity Link intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity conflict, consumed intent, or final login method */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + }; + }; directLogin: { parameters: { query?: never; @@ -10624,6 +11592,259 @@ export interface operations { }; }; }; + accountState: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityLinkAccountStateResponse"]; + }; + }; + /** @description Invalid identity link operation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Fresh reauthentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent belongs to another session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity Link intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity conflict, consumed intent, or final login method */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + }; + }; + getIntent: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"]; + }; + }; + /** @description Invalid identity link operation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Fresh reauthentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent belongs to another session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity Link intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity conflict, consumed intent, or final login method */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + }; + }; + cancel: { + parameters: { + query?: never; + header?: never; + path: { + intentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operation completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityLinkIntentResponse"]; + }; + }; + /** @description Invalid identity link operation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Fresh reauthentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent belongs to another session */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity Link intent was not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity conflict, consumed intent, or final login method */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Intent expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + /** @description Identity provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IdentityLinkErrorResponse"]; + }; + }; + }; + }; listUsers: { parameters: { query?: { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 60bef288..bd3d19d5 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -61,6 +61,57 @@ export interface ChangePasswordRequest { newPassword: string } +type IdentityLinkIntentSchema = components['schemas']['IdentityLinkIntentResponse'] +type IdentityLinkBindingSchema = components['schemas']['IdentityLinkBindingResponse'] +type IdentityLinkProviderSchema = components['schemas']['IdentityLinkProviderResponse'] + +export type IdentityLinkOperation = NonNullable +export type IdentityLinkIntentStatus = NonNullable +export type IdentityProviderLoginMethodType = + NonNullable[number] + +export type IdentityLinkIntent = Omit< + IdentityLinkIntentSchema, + 'id' | 'operation' | 'status' | 'providerCode' | 'expiresAt' +> & { + id: string + operation: IdentityLinkOperation + status: IdentityLinkIntentStatus + providerCode: string + targetBindingId?: number + expiresAt: string +} + +export type IdentityLinkBinding = Omit< + IdentityLinkBindingSchema, + 'bindingId' | 'providerCode' | 'displayName' | 'methodTypes' | 'usable' | 'canUnlink' +> & { + bindingId: number + providerCode: string + displayName: string + methodTypes: IdentityProviderLoginMethodType[] + usable: boolean + canUnlink: boolean +} + +export type IdentityLinkProvider = Omit< + IdentityLinkProviderSchema, + 'providerCode' | 'displayName' | 'methodTypes' +> & { + providerCode: string + displayName: string + methodTypes: IdentityProviderLoginMethodType[] +} + +export interface IdentityLinkAccountState { + localPasswordEnabled: boolean + linkedProviders: IdentityLinkBinding[] + availableProviders: IdentityLinkProvider[] +} + +export type IdentityLinkCredentialRequest = + components['schemas']['IdentityLinkTargetCredentialRequest'] + export interface PasswordResetRequest { email: string } diff --git a/web/src/features/auth/identity-link-manager.test.tsx b/web/src/features/auth/identity-link-manager.test.tsx new file mode 100644 index 00000000..8b66db00 --- /dev/null +++ b/web/src/features/auth/identity-link-manager.test.tsx @@ -0,0 +1,205 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { IdentityLinkAccountState } from '@/api/types' + +let accountState: IdentityLinkAccountState + +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', () => ({ + buildApiUrl: (value: string) => value, +})) + +vi.mock('./use-identity-links', () => ({ + useIdentityLinkAccountState: () => ({ + data: accountState, + isLoading: false, + error: null, + }), + useIdentityLinkIntent: () => ({ + data: undefined, + isLoading: false, + error: null, + }), + useIdentityLinkActions: () => ({ + createLink: mutation(), + createUnlink: mutation(), + cancel: mutation(), + reauthenticateLocal: mutation(), + prepareBrowserReauthentication: mutation(), + reauthenticateCredential: mutation(), + prepareBrowserLink: mutation(), + linkCredential: mutation(), + completeUnlink: mutation(), + }), +})) + +import { + IdentityLinkManager, + parseIdentityLinkCallback, + resumableIdentityLinkIntentId, +} from './identity-link-manager' + +beforeEach(() => { + vi.stubGlobal('window', { + location: { + search: '', + assign: vi.fn(), + }, + }) + accountState = { + localPasswordEnabled: true, + linkedProviders: [ + { + bindingId: 41, + providerCode: 'github', + displayName: 'GitHub', + methodTypes: ['OAUTH_REDIRECT'], + usable: true, + canUnlink: true, + }, + ], + availableProviders: [ + { + providerCode: 'oidc', + displayName: 'Company OIDC', + methodTypes: ['OAUTH_REDIRECT'], + }, + ], + } +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('parseIdentityLinkCallback', () => { + it('accepts only supported callback results', () => { + expect(parseIdentityLinkCallback( + '?identityLink=reauthenticated&intentId=intent-1', + )).toEqual({ + result: 'reauthenticated', + intentId: 'intent-1', + }) + expect(parseIdentityLinkCallback( + '?identityLink=unexpected&intentId=intent-1', + )).toEqual({}) + expect(parseIdentityLinkCallback( + '?identityLink=failed&intentId=intent-1&reasonCode=PROVIDER_UNAVAILABLE', + )).toEqual({ + result: 'failed', + intentId: 'intent-1', + reasonCode: 'PROVIDER_UNAVAILABLE', + }) + expect(parseIdentityLinkCallback( + '?identityLink=failed&intentId=intent-1&reasonCode=UNKNOWN_UPPERCASE_CODE', + )).toEqual({ + result: 'failed', + intentId: 'intent-1', + }) + }) + + it('resumes failed or reauthenticated intents but not completed links', () => { + expect(resumableIdentityLinkIntentId({ + result: 'failed', + intentId: 'intent-1', + })).toBe('intent-1') + expect(resumableIdentityLinkIntentId({ + result: 'reauthenticated', + intentId: 'intent-2', + })).toBe('intent-2') + expect(resumableIdentityLinkIntentId({ + result: 'linked', + intentId: 'intent-3', + })).toBeUndefined() + }) +}) + +describe('IdentityLinkManager', () => { + it('renders local, linked, and available login methods', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('security.identityLinks.localPassword') + expect(html).toContain('GitHub') + expect(html).toContain('Company OIDC') + expect(html).toContain('security.identityLinks.remove') + expect(html).toContain('security.identityLinks.add') + }) + + it('disables removal when the binding is the final login method', () => { + accountState = { + localPasswordEnabled: false, + linkedProviders: [{ + bindingId: 42, + providerCode: 'github', + displayName: 'GitHub', + methodTypes: ['OAUTH_REDIRECT'], + usable: true, + canUnlink: false, + }], + availableProviders: [], + } + + const html = renderToStaticMarkup() + + expect(html).toContain('security.identityLinks.finalMethodHint') + expect(html).toMatch(/]*disabled/) + }) + + it('shows the browser-link success result after callback', () => { + vi.stubGlobal('window', { + location: { + search: '?identityLink=linked&intentId=intent-1', + assign: vi.fn(), + }, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('security.identityLinks.linkSuccess') + expect(html).not.toContain('security.identityLinks.dialogTitle') + }) + + it('reopens a failed browser flow with its resumable intent', () => { + vi.stubGlobal('window', { + location: { + search: '?identityLink=failed&intentId=intent-1', + assign: vi.fn(), + }, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('security.identityLinks.browserFailed') + }) + + it('shows a stable browser failure reason when provided', () => { + vi.stubGlobal('window', { + location: { + search: '?identityLink=failed&intentId=intent-1&reasonCode=PROVIDER_UNAVAILABLE', + assign: vi.fn(), + }, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('security.identityLinks.providerUnavailable') + }) +}) diff --git a/web/src/features/auth/identity-link-manager.tsx b/web/src/features/auth/identity-link-manager.tsx new file mode 100644 index 00000000..ba68c26c --- /dev/null +++ b/web/src/features/auth/identity-link-manager.tsx @@ -0,0 +1,781 @@ +import { useState } from 'react' +import { AlertTriangle, KeyRound, Link2, Loader2, ShieldCheck, Unlink } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { ApiError, buildApiUrl } from '@/api/client' +import type { + IdentityLinkBinding, + IdentityLinkCredentialRequest, + IdentityLinkIntent, + IdentityLinkProvider, +} from '@/api/types' +import { truncateErrorMessage } from '@/shared/lib/error-display' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { + useIdentityLinkAccountState, + useIdentityLinkActions, + useIdentityLinkIntent, +} from './use-identity-links' + +interface IdentityLinkCallback { + result?: 'reauthenticated' | 'linked' | 'failed' + intentId?: string + reasonCode?: string +} + +const identityLinkFailureCodes = new Set([ + 'INTENT_NOT_FOUND', + 'REAUTHENTICATION_REQUIRED', + 'SESSION_MISMATCH', + 'INTENT_EXPIRED', + 'ALREADY_CONSUMED', + 'ACTIVE_INTENT_EXISTS', + 'ACCOUNT_NOT_ELIGIBLE', + 'PROVIDER_UNAVAILABLE', + 'PROVIDER_AUTHENTICATION_FAILED', + 'ALREADY_LINKED', + 'IDENTITY_IN_USE', + 'FINAL_LOGIN_METHOD', + 'INVALID_OPERATION', +]) + +export function parseIdentityLinkCallback(search: string): IdentityLinkCallback { + const params = new URLSearchParams(search) + const result = params.get('identityLink') + const intentId = params.get('intentId') ?? undefined + const rawReasonCode = params.get('reasonCode') + const reasonCode = rawReasonCode + && identityLinkFailureCodes.has(rawReasonCode) + ? rawReasonCode + : undefined + if ( + result !== 'reauthenticated' + && result !== 'linked' + && result !== 'failed' + ) { + return {} + } + return { result, intentId, ...(reasonCode ? { reasonCode } : {}) } +} + +export function resumableIdentityLinkIntentId( + callback: IdentityLinkCallback, +): string | undefined { + return callback.result === 'reauthenticated' || callback.result === 'failed' + ? callback.intentId + : undefined +} + +function currentIdentityLinkCallback(): IdentityLinkCallback { + return typeof window === 'undefined' + ? {} + : parseIdentityLinkCallback(window.location.search) +} + +function browserFailureMessageKey( + reasonCode?: string, +): string { + switch (reasonCode) { + case 'INTENT_EXPIRED': + case 'ALREADY_CONSUMED': + case 'SESSION_MISMATCH': + return 'security.identityLinks.intentUnavailable' + case 'PROVIDER_UNAVAILABLE': + return 'security.identityLinks.providerUnavailable' + case 'FINAL_LOGIN_METHOD': + return 'security.identityLinks.finalMethodHint' + default: + return 'security.identityLinks.browserFailed' + } +} + +function errorMessage(error: unknown, fallback: string) { + return truncateErrorMessage( + error instanceof Error ? error.message : fallback, + ) ?? fallback +} + +function hasMethod( + provider: IdentityLinkBinding | IdentityLinkProvider, + method: 'OAUTH_REDIRECT' | 'DIRECT_PASSWORD', +) { + return provider.methodTypes.includes(method) +} + +function CredentialFields({ + prefix, + value, + disabled, + onChange, +}: { + prefix: string + value: IdentityLinkCredentialRequest + disabled: boolean + onChange: (next: IdentityLinkCredentialRequest) => void +}) { + const { t } = useTranslation() + return ( + <> +

+ + onChange({ + ...value, + username: event.target.value, + })} + /> +
+
+ + onChange({ + ...value, + password: event.target.value, + })} + /> +
+ + ) +} + +export function IdentityLinkManager() { + const { t } = useTranslation() + const callback = currentIdentityLinkCallback() + const resumableIntentId = resumableIdentityLinkIntentId(callback) + const [activeIntentId, setActiveIntentId] = useState( + resumableIntentId, + ) + const [dialogOpen, setDialogOpen] = useState(!!resumableIntentId) + const [localPassword, setLocalPassword] = useState('') + const [credentialProviderCode, setCredentialProviderCode] = useState< + string | undefined + >() + const [credentials, setCredentials] = useState({ + username: '', + password: '', + }) + const [flowError, setFlowError] = useState( + callback.result === 'failed' + ? t(browserFailureMessageKey(callback.reasonCode)) + : '', + ) + const accountQuery = useIdentityLinkAccountState() + const intentQuery = useIdentityLinkIntent(activeIntentId) + const actions = useIdentityLinkActions() + const account = accountQuery.data + const intent = intentQuery.data + + const isPending = Object.values(actions).some( + (mutation) => mutation.isPending, + ) + + function resetFlowFields() { + setLocalPassword('') + setCredentialProviderCode(undefined) + setCredentials({ username: '', password: '' }) + setFlowError('') + } + + function openIntent(nextIntent: IdentityLinkIntent) { + resetFlowFields() + setActiveIntentId(nextIntent.id) + setDialogOpen(true) + } + + async function startLink(provider: IdentityLinkProvider) { + try { + openIntent(await actions.createLink.mutateAsync(provider.providerCode)) + } catch (error) { + setFlowError(errorMessage( + error, + t('security.identityLinks.defaultError'), + )) + } + } + + async function startUnlink(binding: IdentityLinkBinding) { + try { + openIntent(await actions.createUnlink.mutateAsync(binding.bindingId)) + } catch (error) { + setFlowError(errorMessage( + error, + t('security.identityLinks.defaultError'), + )) + } + } + + async function cancelActiveIntent() { + if (!activeIntentId) { + setDialogOpen(false) + return + } + if ( + intentQuery.error instanceof ApiError + && [403, 404, 409, 410].includes(intentQuery.error.status) + ) { + setDialogOpen(false) + setActiveIntentId(undefined) + resetFlowFields() + return + } + try { + await actions.cancel.mutateAsync(activeIntentId) + setDialogOpen(false) + setActiveIntentId(undefined) + resetFlowFields() + } catch (error) { + setFlowError(errorMessage( + error, + t('security.identityLinks.defaultError'), + )) + } + } + + function handleDialogOpenChange(open: boolean) { + if (open) { + setDialogOpen(true) + return + } + if (intent?.status === 'COMPLETED' || intent?.status === 'CANCELLED') { + setDialogOpen(false) + setActiveIntentId(undefined) + resetFlowFields() + return + } + void cancelActiveIntent() + } + + async function reauthenticateLocal(event: React.FormEvent) { + event.preventDefault() + if (!activeIntentId || !localPassword) { + setFlowError(t('security.identityLinks.passwordRequired')) + return + } + setFlowError('') + try { + await actions.reauthenticateLocal.mutateAsync({ + intentId: activeIntentId, + password: localPassword, + }) + setLocalPassword('') + } catch (error) { + setFlowError(errorMessage( + error, + t('security.identityLinks.reauthenticationFailed'), + )) + } + } + + async function reauthenticateCredential( + event: React.FormEvent, + ) { + event.preventDefault() + if ( + !activeIntentId + || !credentialProviderCode + || !credentials.username.trim() + || !credentials.password + ) { + setFlowError(t('security.identityLinks.credentialsRequired')) + return + } + setFlowError('') + try { + await actions.reauthenticateCredential.mutateAsync({ + intentId: activeIntentId, + providerCode: credentialProviderCode, + credentials: { + username: credentials.username.trim(), + password: credentials.password, + }, + }) + setCredentials({ username: '', password: '' }) + setCredentialProviderCode(undefined) + } catch (error) { + setFlowError(errorMessage( + error, + t('security.identityLinks.reauthenticationFailed'), + )) + } + } + + async function redirectToBrowserReauthentication(providerCode: string) { + if (!activeIntentId) return + setFlowError('') + try { + const actionUrl = + await actions.prepareBrowserReauthentication.mutateAsync({ + intentId: activeIntentId, + providerCode, + }) + window.location.assign(buildApiUrl(actionUrl)) + } catch (error) { + setFlowError(errorMessage( + error, + t('security.identityLinks.reauthenticationFailed'), + )) + } + } + + async function redirectToBrowserLink() { + if (!activeIntentId) return + setFlowError('') + try { + const actionUrl = await actions.prepareBrowserLink.mutateAsync( + activeIntentId, + ) + window.location.assign(buildApiUrl(actionUrl)) + } catch (error) { + setFlowError(errorMessage( + error, + t('security.identityLinks.linkFailed'), + )) + } + } + + async function linkCredential(event: React.FormEvent) { + event.preventDefault() + if ( + !activeIntentId + || !credentials.username.trim() + || !credentials.password + ) { + setFlowError(t('security.identityLinks.credentialsRequired')) + return + } + setFlowError('') + try { + await actions.linkCredential.mutateAsync({ + intentId: activeIntentId, + credentials: { + username: credentials.username.trim(), + password: credentials.password, + }, + }) + setDialogOpen(false) + setActiveIntentId(undefined) + resetFlowFields() + } catch (error) { + setFlowError(errorMessage( + error, + t('security.identityLinks.linkFailed'), + )) + } + } + + async function completeUnlink() { + if (!activeIntentId) return + setFlowError('') + try { + await actions.completeUnlink.mutateAsync(activeIntentId) + setDialogOpen(false) + setActiveIntentId(undefined) + resetFlowFields() + } catch (error) { + setFlowError(errorMessage( + error, + t('security.identityLinks.unlinkFailed'), + )) + } + } + + const linkedProviders = account?.linkedProviders ?? [] + const targetProvider = intent?.operation === 'LINK' + ? account?.availableProviders.find( + (provider) => provider.providerCode === intent.providerCode, + ) + : undefined + const targetBinding = intent?.operation === 'UNLINK' + ? linkedProviders.find( + (binding) => binding.bindingId === intent.targetBindingId, + ) + : undefined + const browserReauthenticationProviders = linkedProviders.filter( + (provider) => provider.usable && hasMethod(provider, 'OAUTH_REDIRECT'), + ) + const credentialReauthenticationProviders = linkedProviders.filter( + (provider) => provider.usable && hasMethod(provider, 'DIRECT_PASSWORD'), + ) + const hasFreshReauthenticationMethod = + account?.localPasswordEnabled + || browserReauthenticationProviders.length > 0 + || credentialReauthenticationProviders.length > 0 + + return ( + <> + + + {t('security.identityLinks.title')} + + {t('security.identityLinks.subtitle')} + + + + {callback.result === 'linked' ? ( +
+ {t('security.identityLinks.linkSuccess')} +
+ ) : null} + {callback.result === 'failed' ? ( +
+ {t(browserFailureMessageKey(callback.reasonCode))} +
+ ) : null} + {flowError && !dialogOpen ? ( +
+ {flowError} +
+ ) : null} + + {accountQuery.isLoading ? ( +
+ + {t('security.identityLinks.loading')} +
+ ) : accountQuery.error ? ( +

+ {errorMessage( + accountQuery.error, + t('security.identityLinks.defaultError'), + )} +

+ ) : ( + <> +
+

+ {t('security.identityLinks.linkedTitle')} +

+ {account?.localPasswordEnabled ? ( +
+
+ +
+

+ {t('security.identityLinks.localPassword')} +

+

+ {t('security.identityLinks.localPasswordDescription')} +

+
+
+ + {t('security.identityLinks.active')} + +
+ ) : null} + {linkedProviders.map((provider) => ( +
+
+ +
+

+ {provider.displayName} +

+

+ {provider.usable + ? t('security.identityLinks.externalLogin') + : t('security.identityLinks.providerUnavailable')} +

+
+
+ +
+ ))} + {!account?.localPasswordEnabled && linkedProviders.length === 0 ? ( +

+ {t('security.identityLinks.noLinkedMethods')} +

+ ) : null} +
+ +
+

+ {t('security.identityLinks.availableTitle')} +

+ {(account?.availableProviders ?? []).map((provider) => ( +
+
+ +
+

{provider.displayName}

+

+ {t('security.identityLinks.availableDescription')} +

+
+
+ +
+ ))} + {account?.availableProviders.length === 0 ? ( +

+ {t('security.identityLinks.noAvailableMethods')} +

+ ) : null} +
+ + )} +
+
+ + + + + {t('security.identityLinks.dialogTitle')} + + {intent?.operation === 'UNLINK' + ? t('security.identityLinks.unlinkDialogDescription', { + name: targetBinding?.displayName ?? intent.providerCode, + }) + : t('security.identityLinks.linkDialogDescription', { + name: targetProvider?.displayName ?? intent?.providerCode ?? '', + })} + + + + {intentQuery.isLoading ? ( +
+ + {t('security.identityLinks.loadingIntent')} +
+ ) : intentQuery.error ? ( +

+ {errorMessage( + intentQuery.error, + t('security.identityLinks.defaultError'), + )} +

+ ) : null} + + {intent?.status === 'PENDING_REAUTHENTICATION' ? ( +
+
+ {t('security.identityLinks.reauthenticationDescription')} +
+ + {account?.localPasswordEnabled ? ( +
+ + setLocalPassword(event.target.value)} + /> + +
+ ) : null} + + {browserReauthenticationProviders.length > 0 ? ( +
+

+ {t('security.identityLinks.verifyWithProvider')} +

+
+ {browserReauthenticationProviders.map((provider) => ( + + ))} +
+
+ ) : null} + + {credentialReauthenticationProviders.length > 0 ? ( +
+

+ {t('security.identityLinks.verifyWithCredentials')} +

+
+ {credentialReauthenticationProviders.map((provider) => ( + + ))} +
+ {credentialProviderCode ? ( +
+ + + + ) : null} +
+ ) : null} + + {!hasFreshReauthenticationMethod ? ( +
+ + {t('security.identityLinks.noReauthenticationMethod')} +
+ ) : null} +
+ ) : null} + + {intent?.status === 'READY' && intent.operation === 'LINK' ? ( +
+
+ {t('security.identityLinks.reauthenticated')} +
+ {targetProvider && hasMethod(targetProvider, 'OAUTH_REDIRECT') ? ( + + ) : null} + {targetProvider && hasMethod(targetProvider, 'DIRECT_PASSWORD') ? ( +
+ + + + ) : null} + {!targetProvider ? ( +

+ {t('security.identityLinks.providerUnavailable')} +

+ ) : null} +
+ ) : null} + + {intent?.status === 'READY' && intent.operation === 'UNLINK' ? ( +
+
+ + {t('security.identityLinks.unlinkConfirmation', { + name: targetBinding?.displayName ?? intent.providerCode, + })} +
+ +
+ ) : null} + + {intent && ( + intent.status === 'EXPIRED' + || intent.status === 'CANCELLED' + ) ? ( +

+ {t('security.identityLinks.intentUnavailable')} +

+ ) : null} + + {flowError && dialogOpen ? ( +

{flowError}

+ ) : null} + + + + +
+
+ + ) +} diff --git a/web/src/features/auth/use-identity-links.ts b/web/src/features/auth/use-identity-links.ts new file mode 100644 index 00000000..63be2d81 --- /dev/null +++ b/web/src/features/auth/use-identity-links.ts @@ -0,0 +1,132 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { identityLinkApi } from '@/api/client' +import type { + IdentityLinkCredentialRequest, + IdentityLinkIntent, +} from '@/api/types' + +export const identityLinkKeys = { + account: ['auth', 'identity-links'] as const, + intent: (intentId: string) => + ['auth', 'identity-link-intent', intentId] as const, +} + +export function useIdentityLinkAccountState() { + return useQuery({ + queryKey: identityLinkKeys.account, + queryFn: identityLinkApi.getAccountState, + staleTime: 15_000, + }) +} + +export function useIdentityLinkIntent(intentId?: string) { + return useQuery({ + queryKey: identityLinkKeys.intent(intentId ?? ''), + queryFn: () => identityLinkApi.getIntent(intentId ?? ''), + enabled: !!intentId, + retry: false, + }) +} + +export function useIdentityLinkActions() { + const queryClient = useQueryClient() + + function cacheIntent(intent: IdentityLinkIntent) { + queryClient.setQueryData( + identityLinkKeys.intent(intent.id), + intent, + ) + } + + async function refreshAccountState() { + await queryClient.invalidateQueries({ + queryKey: identityLinkKeys.account, + }) + } + + const createLink = useMutation({ + mutationFn: identityLinkApi.createLinkIntent, + onSuccess: cacheIntent, + }) + const createUnlink = useMutation({ + mutationFn: identityLinkApi.createUnlinkIntent, + onSuccess: cacheIntent, + }) + const cancel = useMutation({ + mutationFn: identityLinkApi.cancel, + onSuccess: cacheIntent, + }) + const reauthenticateLocal = useMutation({ + mutationFn: ({ + intentId, + password, + }: { + intentId: string + password: string + }) => identityLinkApi.reauthenticateLocal(intentId, password), + onSuccess: cacheIntent, + }) + const prepareBrowserReauthentication = useMutation({ + mutationFn: ({ + intentId, + providerCode, + }: { + intentId: string + providerCode: string + }) => identityLinkApi.prepareBrowserReauthentication( + intentId, + providerCode, + ), + }) + const reauthenticateCredential = useMutation({ + mutationFn: ({ + intentId, + providerCode, + credentials, + }: { + intentId: string + providerCode: string + credentials: IdentityLinkCredentialRequest + }) => identityLinkApi.reauthenticateCredential( + intentId, + providerCode, + credentials, + ), + onSuccess: cacheIntent, + }) + const prepareBrowserLink = useMutation({ + mutationFn: identityLinkApi.prepareBrowserLink, + }) + const linkCredential = useMutation({ + mutationFn: ({ + intentId, + credentials, + }: { + intentId: string + credentials: IdentityLinkCredentialRequest + }) => identityLinkApi.linkCredential(intentId, credentials), + onSuccess: async (intent) => { + cacheIntent(intent) + await refreshAccountState() + }, + }) + const completeUnlink = useMutation({ + mutationFn: identityLinkApi.completeUnlink, + onSuccess: async (intent) => { + cacheIntent(intent) + await refreshAccountState() + }, + }) + + return { + createLink, + createUnlink, + cancel, + reauthenticateLocal, + prepareBrowserReauthentication, + reauthenticateCredential, + prepareBrowserLink, + linkCredential, + completeUnlink, + } +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 0a35eec5..af0c9128 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -762,7 +762,52 @@ "unavailableTitle": "Password changes are unavailable for this account.", "unavailableDescription": "This account signs in through an external identity provider or has no local password credential.", "submitting": "Submitting...", - "submit": "Update Password" + "submit": "Update Password", + "identityLinks": { + "title": "Login Methods", + "subtitle": "Add or remove ways to sign in. Security-sensitive changes require you to verify your current account again.", + "linkedTitle": "Linked login methods", + "availableTitle": "Available login methods", + "localPassword": "Local password", + "localPasswordDescription": "Sign in with your SkillHub username and password.", + "externalLogin": "External identity provider", + "active": "Active", + "providerUnavailable": "This identity provider is currently unavailable.", + "remove": "Remove", + "add": "Add", + "availableDescription": "Connect this identity provider to your current account.", + "noLinkedMethods": "No usable login method is currently linked.", + "noAvailableMethods": "There are no additional login methods available.", + "finalMethodHint": "You cannot remove the final usable login method.", + "loading": "Loading login methods...", + "loadingIntent": "Loading security verification...", + "dialogTitle": "Verify account control", + "linkDialogDescription": "Verify your current account before linking {{name}}.", + "unlinkDialogDescription": "Verify your current account before removing {{name}}.", + "reauthenticationDescription": "For your security, verify one of the login methods already linked to this account.", + "verifyPassword": "Verify password", + "verifyWithProvider": "Verify in your browser", + "verifyWithCredentials": "Verify with provider credentials", + "verifyCredentials": "Verify credentials", + "username": "Username", + "password": "Password", + "passwordRequired": "Enter your current password.", + "credentialsRequired": "Enter both username and password.", + "reauthenticated": "Current account verified. Now authenticate the login method you want to link.", + "continueWithProvider": "Continue with {{name}}", + "linkProvider": "Link {{name}}", + "unlinkConfirmation": "Removing {{name}} prevents future sign-ins through this identity. Existing platform data is not deleted.", + "confirmRemove": "Remove login method", + "cancel": "Cancel", + "noReauthenticationMethod": "None of the linked login methods currently supports fresh verification. Cancel this request and contact an administrator.", + "intentUnavailable": "This security request has expired or was cancelled. Start again from the login methods list.", + "linkSuccess": "The login method was linked successfully.", + "browserFailed": "Browser verification failed or was cancelled. No login method was changed.", + "reauthenticationFailed": "Could not verify the current account.", + "linkFailed": "Could not link this login method.", + "unlinkFailed": "Could not remove this login method.", + "defaultError": "Could not update login methods. Please try again." + } }, "accounts": { "unavailableTitle": "Account merging is temporarily unavailable", @@ -1455,6 +1500,7 @@ "notFound": "The requested resource was not found", "serverError": "Server error, please try again later", "networkError": "Network connection failed, please check your network", + "invalidResponse": "The server returned an invalid response", "unknown": "Operation failed" }, "routeGuard": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 49b0c3c0..94aa73e9 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -762,7 +762,52 @@ "unavailableTitle": "此账号暂不可修改密码。", "unavailableDescription": "此账号通过外部身份提供方登录,或尚未配置本地密码凭据。", "submitting": "提交中...", - "submit": "更新密码" + "submit": "更新密码", + "identityLinks": { + "title": "登录方式", + "subtitle": "添加或移除账号登录方式。涉及账号安全的变更需要重新验证当前账号。", + "linkedTitle": "已关联的登录方式", + "availableTitle": "可添加的登录方式", + "localPassword": "本地密码", + "localPasswordDescription": "使用 SkillHub 用户名和密码登录。", + "externalLogin": "外部身份提供方", + "active": "可用", + "providerUnavailable": "此身份提供方当前不可用。", + "remove": "移除", + "add": "添加", + "availableDescription": "将此身份提供方关联到当前账号。", + "noLinkedMethods": "当前账号没有可用的登录方式。", + "noAvailableMethods": "当前没有其他可添加的登录方式。", + "finalMethodHint": "不能移除最后一种可用登录方式。", + "loading": "正在加载登录方式……", + "loadingIntent": "正在加载安全验证……", + "dialogTitle": "验证账号控制权", + "linkDialogDescription": "关联 {{name}} 前,请重新验证当前账号。", + "unlinkDialogDescription": "移除 {{name}} 前,请重新验证当前账号。", + "reauthenticationDescription": "为保护账号安全,请使用当前账号已有的一种登录方式重新验证。", + "verifyPassword": "验证密码", + "verifyWithProvider": "通过浏览器验证", + "verifyWithCredentials": "使用身份提供方凭据验证", + "verifyCredentials": "验证凭据", + "username": "用户名", + "password": "密码", + "passwordRequired": "请输入当前密码。", + "credentialsRequired": "请输入用户名和密码。", + "reauthenticated": "当前账号验证成功。现在请认证要关联的目标登录方式。", + "continueWithProvider": "继续使用 {{name}}", + "linkProvider": "关联 {{name}}", + "unlinkConfirmation": "移除 {{name}} 后将不能再通过该身份登录,但不会删除平台内已有数据。", + "confirmRemove": "移除登录方式", + "cancel": "取消", + "noReauthenticationMethod": "当前已关联的登录方式均不支持重新验证。请取消本次操作并联系管理员。", + "intentUnavailable": "本次安全请求已过期或取消,请从登录方式列表重新发起。", + "linkSuccess": "登录方式关联成功。", + "browserFailed": "浏览器验证失败或已取消,登录方式没有发生变更。", + "reauthenticationFailed": "当前账号验证失败。", + "linkFailed": "登录方式关联失败。", + "unlinkFailed": "登录方式移除失败。", + "defaultError": "登录方式更新失败,请稍后重试。" + } }, "accounts": { "unavailableTitle": "账号合并暂时不可用", @@ -1456,6 +1501,7 @@ "notFound": "请求的资源不存在", "serverError": "服务器错误,请稍后重试", "networkError": "网络连接失败,请检查网络", + "invalidResponse": "服务器返回了无效响应", "unknown": "操作失败" }, "routeGuard": { diff --git a/web/src/pages/settings/security.test.tsx b/web/src/pages/settings/security.test.tsx index 9e2955a7..ee8c4315 100644 --- a/web/src/pages/settings/security.test.tsx +++ b/web/src/pages/settings/security.test.tsx @@ -36,6 +36,10 @@ vi.mock('@/features/auth/use-auth', () => ({ useAuth: useAuthMock, })) +vi.mock('@/features/auth/identity-link-manager', () => ({ + IdentityLinkManager: () =>
identity-link-manager
, +})) + vi.mock('@/shared/lib/error-display', () => ({ truncateErrorMessage: (v: string) => v, })) @@ -93,6 +97,7 @@ describe('SecuritySettingsPage', () => { it('renders the password form when password changes are allowed', () => { const html = renderToStaticMarkup() + expect(html).toContain('identity-link-manager') expect(html).toContain('security.currentPassword') expect(html).toContain('security.newPassword') expect(html).toContain('security.submit') diff --git a/web/src/pages/settings/security.tsx b/web/src/pages/settings/security.tsx index 3726ad7d..b2675623 100644 --- a/web/src/pages/settings/security.tsx +++ b/web/src/pages/settings/security.tsx @@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { ApiError, authApi } from '@/api/client' +import { IdentityLinkManager } from '@/features/auth/identity-link-manager' import { useAuth } from '@/features/auth/use-auth' import { clearSessionScopedQueries } from '@/features/notification/notification-session' import { truncateErrorMessage } from '@/shared/lib/error-display' @@ -20,9 +21,9 @@ function canUsePasswordChangeForm(user?: PasswordChangeCapabilityUser | null) { } /** - * Security settings page for password changes. After a successful change the - * user is logged out so all existing authenticated state is re-established with - * the new credential. + * Security settings page for login methods and password changes. After a + * successful password change the user is logged out so all existing + * authenticated state is re-established with the new credential. */ export function SecuritySettingsPage() { const { t } = useTranslation() @@ -87,7 +88,8 @@ export function SecuritySettingsPage() { } return ( -
+
+ {t('security.title')} diff --git a/web/src/shared/lib/api-error.ts b/web/src/shared/lib/api-error.ts index 03efebca..879e9358 100644 --- a/web/src/shared/lib/api-error.ts +++ b/web/src/shared/lib/api-error.ts @@ -17,6 +17,7 @@ export class ApiError extends Error { public status: number, public serverMessage?: string, public serverMessageKey?: string, + public reasonCode?: string, ) { super(resolveLocalizedMessage(message) || message) this.name = 'ApiError' diff --git a/web/vite.config.ts b/web/vite.config.ts index c5abfe3b..6da5dd8c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -4,6 +4,7 @@ import path from 'path' const JS_BUILD_TARGET = 'es2020' const LEGACY_BROWSER_TARGETS = ['chrome83', 'edge83', 'firefox78', 'safari14'] +const DEV_API_TARGET = process.env.SKILLHUB_DEV_API_TARGET?.trim() || 'http://localhost:8080' export default defineConfig({ plugins: [react()], @@ -34,12 +35,16 @@ export default defineConfig({ }, proxy: { '/api': { - target: 'http://localhost:8080', + target: DEV_API_TARGET, changeOrigin: true, }, '/oauth2': { - target: 'http://localhost:8080', - changeOrigin: true, + target: DEV_API_TARGET, + changeOrigin: false, + }, + '/login/oauth2/code': { + target: DEV_API_TARGET, + changeOrigin: false, }, }, },