diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index b9db4be4..1edc16f5 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -1,10 +1,11 @@ # skillhub 认证与授权设计 > 外部身份架构说明:LDAP、DingTalk、CAS、SAML、可信代理及其他新外部身份接入, -> 以 [统一身份联邦设计](./21-unified-identity-federation-design.md) 为准。本文现有 -> `OAuthClaims`、`DirectAuthProvider` 和 `PassiveSessionAuthenticator` 接口描述的是 -> 当前兼容实现,不是新 Provider 的目标扩展契约;新 Provider 只能返回协议验证结果, -> 由统一核心归一化为 `IdentityAssertion`,不得直接返回 `PlatformPrincipal`。 +> 以 [统一身份联邦设计](./21-unified-identity-federation-design.md) 为准。GitHub、 +> GitLab 和标准 OIDC 已迁入统一身份核心;`DirectAuthProvider` 和 +> `PassiveSessionAuthenticator` 仍是兼容扩展点,不是新 Provider 的目标契约。新 +> Provider 只能返回协议验证结果,由统一核心归一化为内部 `IdentityAssertion`,不得 +> 直接返回 `PlatformPrincipal`。 ## 0. 身份标识约束 @@ -27,14 +28,14 @@ │ OAuth2User ▼ ┌─────────────────────────────┐ -│ Layer 2: Access Policy │ 准入策略判定 -│ (认证成功 ≠ 有权使用平台) │ 白名单/邮箱域名/开放注册 +│ Layer 2: Identity Core │ 受信 descriptor + Authority Lock +│ │ Assertion Factory + 账号状态守卫 └─────────────┬───────────────┘ - │ 准入通过 + │ IdentityAssertion ▼ ┌─────────────────────────────┐ -│ Layer 3: Identity Mapping │ OAuth2 用户 → 平台用户 -│ (查询/创建 identity_binding) │ 自动注册 + 信息同步 +│ Layer 3: Policy + Mapping │ 准入策略 + identity_binding +│ │ 兼容建号与资料同步 └─────────────┬───────────────┘ │ PlatformPrincipal ▼ @@ -54,18 +55,18 @@ OAuth 认证成功仅代表身份可信,不代表有权使用平台。准入层在认证成功后、创建平台用户前执行。 ```java -// 基于 claims 的准入策略,与 Provider 无关 +// 基于统一身份上下文的准入策略,与底层协议无关 public interface AccessPolicy { - AccessDecision evaluate(OAuthClaims claims); + AccessDecision evaluate(IdentityAccessContext context); } -public record OAuthClaims( - String provider, // github, google, wechat - String subject, // provider 唯一 ID - String email, // nullable(微信等可能无邮箱) - boolean emailVerified, // 是否已验证 - String providerLogin, // 如 GitHub login - Map extra +public record IdentityAccessContext( + String providerCode, + String subjectType, + String subject, + Optional email, + EmailAssurance emailAssurance, + IdentityLoginContext requestContext ) {} public enum AccessDecision { @@ -91,9 +92,9 @@ astron: | 策略 | 判定依据 | 说明 | |------|---------|------| | `OPEN` | 无限制 | 所有 OAuth 登录用户自动准入 | -| `PROVIDER_ALLOWLIST` | `claims.provider` | 仅允许指定 Provider 登录 | -| `EMAIL_DOMAIN` | `claims.email` + `claims.emailVerified` | 仅允许已验证邮箱且域名匹配(email 为空或未验证则 DENY) | -| `SUBJECT_WHITELIST` | `claims.provider` + `claims.subject` | 按 `provider:subject` 白名单,管理员预添加 | +| `PROVIDER_ALLOWLIST` | `context.providerCode` | 仅允许指定 Provider 登录 | +| `EMAIL_DOMAIN` | `context.email` + `context.emailAssurance` | 仅允许 `VERIFIED` / `AUTHORITATIVE` 邮箱且域名匹配 | +| `SUBJECT_WHITELIST` | `context.providerCode` + `context.subject` | 按 `provider:subject` 白名单,管理员预添加 | ### 2.2 准入失败处理 @@ -132,14 +133,17 @@ Spring Security 自动完成: │ ▼ CustomOAuth2UserService / CustomOidcUserService: - ① 从 OAuth2User 提取 provider + externalId → 构建 OAuthClaims - ② AccessPolicy.evaluate(claims) → 准入判定 + ① Adapter 从已验证响应提取 ProviderAuthenticationResult + ② 服务端路由解析 ResolvedProviderHandle + ③ 统一身份核心读取受信 descriptor,执行 Authority pin/复核 + ④ Assertion Factory 固定 provider/authority/subject/属性映射 + ⑤ AccessPolicy.evaluate(IdentityAccessContext) → 准入判定 │ ├── DENY → 抛出 OAuth2AccessDeniedException → failureHandler 重定向 /access-denied(不建立 Session) ├── PENDING_APPROVAL → 创建 PENDING 用户 → 抛出 AccountPendingException → failureHandler 重定向 /pending-approval(不建立 Session) └── ALLOW ↓ │ - ③ 查询 identity_binding 是否已绑定 + ⑥ 查询 identity_binding 是否已绑定 ├── 已绑定 → 加载平台用户,检查用户状态(DISABLED → 抛异常),同步最新头像/昵称 └── 未绑定 → 创建 user_account(ACTIVE) + identity_binding │ @@ -151,18 +155,18 @@ AuthenticationSuccessHandler: OIDC 登录沿用同一条业务链路,但由 Spring Security 的 `oidcUserService` 分支处理。`CustomOidcUserService` 会把标准 OIDC claims 映射为 -`OAuthClaims`: +`ProviderAuthenticationResult`: -- `provider`:Spring OAuth2 client registration id,例如 `okta`、`keycloak` - 或 `oidc` -- `subject`:OIDC `sub` -- `email` / `emailVerified`:`email` 与 `email_verified` -- `providerLogin`:优先 `preferred_username`,其次 `name`、`email`、`sub` -- `picture` 会同步为 `avatar_url`,供现有头像同步逻辑复用 +- Subject candidate:类型固定为 `oidc_sub`,值为大小写敏感的 OIDC `sub` +- 属性事实:`email`、`email_verified`、`preferred_username`、`name`、`picture` +- 协议证据:只包含 `oidc`、认证时间和认证方法,不包含 token 或原始响应 +- Provider code、issuer Authority 和最终属性映射由服务端受信 descriptor 固定 -因此 OIDC 不需要新增数据库表;现有 `identity_binding(provider_code, -subject)` 可以保存任意 OIDC issuer 下的稳定用户标识。不同 IdP 应使用不同 -registration id,避免多个 issuer 的 `sub` 值空间混用。 +现有 `identity_binding(provider_code, subject)` 继续保存历史和新登录绑定,不改变 +Subject 值。新增的 `identity_provider_state` 只保存 Provider code、protocol、 +canonical Authority、SHA-256 fingerprint 和状态,不保存 client secret 或 token。 +同一 registration id 切换 issuer 时进入粘性的 `AUTHORITY_MISMATCH`,不展示登录方式, +也不接受回调;恢复旧 Authority 后仍需显式恢复操作。 ### 3.1 统一 Session 建立约束 @@ -262,9 +266,9 @@ public class SecurityConfig { } ``` -### 3.6 OAuth2 Provider 扩展设计 +### 3.6 Provider 配置、启动协调和扩展边界 -一期只实现 GitHub,但架构支持后续扩展: +当前静态 descriptor source 只接受已配置且可唯一解析的 GitHub、GitLab 和标准 OIDC: ```yaml # application.yml @@ -285,76 +289,67 @@ spring: # client-id: ... ``` -Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 只需: -1. `application.yml` 添加 registration 配置 -2. `CustomOAuth2UserService` 中按 `registrationId` 分支处理用户属性映射 -3. 前端登录页增加对应按钮(通过 `/api/v1/auth/providers` 自动发现) +应用启动时固定执行以下顺序: + +1. 从唯一的受信 descriptor source 读取已启用配置。 +2. 对每个 Provider 在 PostgreSQL 中执行 Authority compare-and-set pin。 +3. 再次读取持久化状态。 +4. 登录目录每次读取时再次以持久化状态过滤;只有状态为 `READY` 且 fingerprint 与 + 当前 descriptor 一致的 Provider 才进入 `/api/v1/auth/providers` 和 + `/api/v1/auth/methods`。 + +配置缺失、placeholder、未知/歧义协议、Authority 无法唯一确定、未 pin 或 mismatch +都 fail closed。授权入口和 callback 在 Spring Security 发起上游重定向、Token 交换或 +userinfo 请求前执行相同的持久化 readiness 检查;登录目录不会直接读取 +`OAuth2ClientProperties`,其他 Pod 写入的 mismatch 也不会被旧的内存投影继续展示。 + +运维把全部 Pod 恢复为已 pin 的相同 Authority 后,由 `SUPER_ADMIN` 调用: + +```text +POST /api/v1/admin/identity-providers/{providerCode}/authority/recover +``` + +该操作不接受新 Authority 或 fingerprint,只能在数据库仍为 `AUTHORITY_MISMATCH` 且当前 +受信 descriptor 的 fingerprint 等于已 pin 值时 compare-and-set 回 `READY`。成功变更 +写入同一事务的 `PROVIDER_AUTHORITY_RECOVERED` 审计;重复调用返回 +`recovered=false, state=READY`,不会伪造第二条恢复审计。配置仍指向新 Authority 时返回 +冲突,必须等待后续独立的 Authority 迁移设计,不能用此接口改写 pin。 + +新增协议不能只添加 Spring registration 或在 OAuth user service 中加分支。必须按照 +[统一身份联邦设计](./21-unified-identity-federation-design.md) 实现受信 descriptor、 +协议 Adapter 和 conformance 测试;LDAP、DingTalk、CAS、SAML、SCIM 及动态 Provider +Registry 不属于当前阶段。 ## 4. 核心接口设计 ```java -// 自定义 OAuth2 用户服务,处理准入 + 用户映射 -@Service -public class CustomOAuth2UserService extends DefaultOAuth2UserService { - - @Override - public OAuth2User loadUser(OAuth2UserRequest request) { - OAuth2User oAuth2User = super.loadUser(request); - String registrationId = request.getClientRegistration().getRegistrationId(); - - // 提取标准化 claims(传入 accessToken 用于调用 Provider API,如 GitHub /user/emails) - OAuthClaims claims = OAuthClaimsExtractor.extract(registrationId, oAuth2User, request.getAccessToken()); - - // 准入策略判定(基于 claims,与 Provider 无关) - AccessDecision decision = accessPolicy.evaluate(claims); - if (decision == AccessDecision.DENY) { - throw new OAuth2AccessDeniedException("Access denied by policy"); - } - if (decision == AccessDecision.PENDING_APPROVAL) { - // 创建 PENDING 用户但不返回有效 principal,不建立业务 Session - identityBindingService.createPendingUser(registrationId, claims); - throw new AccountPendingException("Account pending approval"); - } - - // 绑定或创建平台用户(仅 ALLOW 才走到这里) - UserAccount account = identityBindingService.bindOrCreate(registrationId, claims); - if (account.getStatus() == UserStatus.DISABLED) { - throw new AccountDisabledException("Account is disabled"); - } - - return new PlatformOAuth2User(account, oAuth2User.getAuthorities()); - } -} - -// 按 Provider 提取标准化 claims(每个 Provider 有自己的可信字段契约) -public class OAuthClaimsExtractor { - public static OAuthClaims extract(String registrationId, OAuth2User user, - OAuth2AccessToken accessToken) { - return switch (registrationId) { - case "github" -> extractGitHub(user, accessToken); - // 后续扩展其他 Provider - default -> throw new OAuth2AuthenticationException("Unsupported provider: " + registrationId); - }; - } - - // GitHub: 公开 email 可能为空,需调用 /user/emails API 获取已验证邮箱 - private static OAuthClaims extractGitHub(OAuth2User user, OAuth2AccessToken accessToken) { - String verifiedEmail = GitHubEmailFetcher.fetchVerifiedEmail(accessToken); - return new OAuthClaims( - "github", - String.valueOf(user.getAttribute("id")), - verifiedEmail, // 从 /user/emails 获取的已验证邮箱,可能为 null - verifiedEmail != null, // 只有确认 verified 才为 true - user.getAttribute("login"), - Map.of("avatar_url", user.getAttribute("avatar_url")) - ); - } - - // GitHubEmailFetcher: 调用 GitHub /user/emails API, - // 返回 primary + verified 的邮箱,无则返回 null +public interface ExternalIdentityLoginService { + IdentityLoginOutcome authenticate( + ResolvedProviderHandle provider, + ProviderAuthenticationResult result, + IdentityLoginContext context + ); } ``` +`ResolvedProviderHandle` 只能由服务端 `ClientRegistration` 路由解析产生。 +`ProviderAuthenticationResult` 不含 Provider code、Authority、平台 userId、角色、 +Principal、Session、token、ticket、Cookie 或原始响应。核心内部按固定顺序执行: + +```text +Trusted descriptor + → Authority Lock + → IdentityAssertionFactory + → AccessPolicy + → identity_binding / 兼容建号 + → AccountLoginGuard + → PlatformPrincipalFactory + → IdentityLoginOutcome +``` + +只有 `IdentityLoginOutcome.Authenticated` 可以到达既有 `PlatformSessionService`。当前 +`identity_binding` 和 `PlatformPrincipal` 结构保持不变,以支持老版本升级和回滚。 + ### 4.1 多 Provider 账号合并策略 同一个员工通过不同 OAuth Provider 登录时,可能产生多个 `user_account`。 @@ -363,7 +358,7 @@ public class OAuthClaimsExtractor { token 直接返回给主账号会话,不能分别证明两个账号的控制权,因此不能继续作为管理员或 用户合并入口。 -- 一期 GitHub-only:不需要自动合并,每个 Provider 登录独立创建用户 +- 当前阶段:不自动合并,每个 Provider 登录独立创建用户 - 多 Provider 上线时,再引入显式 Identity Link 和安全 Account Merge - email、username、display name 或主账号会话拿到的 token 均不能证明次账号所有权 - 安全 Account Merge 必须要求主、次账号分别完成 fresh reauthentication diff --git a/docs/21-unified-identity-federation-design.md b/docs/21-unified-identity-federation-design.md index 7c86e2ac..407f9d09 100644 --- a/docs/21-unified-identity-federation-design.md +++ b/docs/21-unified-identity-federation-design.md @@ -505,6 +505,18 @@ fingerprint 等于数据库已 pin 值且 state 仍为 `AUTHORITY_MISMATCH` 时 回 `READY`,不修改 Authority/fingerprint,并写恢复审计。若配置仍是新 fingerprint, 只能使用待设计的显式 Authority 迁移操作。 +PR 1 的正式恢复入口为: + +```text +POST /api/v1/admin/identity-providers/{providerCode}/authority/recover +``` + +入口只允许 `SUPER_ADMIN`,不接收 Authority 或 fingerprint 请求体。状态实际从 +`AUTHORITY_MISMATCH` 转为 `READY` 时,在同一数据库事务写 +`PROVIDER_AUTHORITY_RECOVERED` 审计;已是 `READY` 的重复请求按幂等结果返回但不追加伪 +恢复审计。所有 Pod 的登录目录和授权入口必须读取当前持久化状态,使一次恢复无需重启 +应用即可生效。 + ### 6.4 External Subject 外部身份域内稳定、不可变的主体标识,由 `subjectType + subjectValue` 表达。email、 diff --git a/docs/verification/issue-640.md b/docs/verification/issue-640.md new file mode 100644 index 00000000..8abb99b0 --- /dev/null +++ b/docs/verification/issue-640.md @@ -0,0 +1,71 @@ +# Issue #640 unified identity core verification + +## Scope + +- Issue: +- Pull request: +- Integration target: `big-main` +- Validation environment: dedicated Hong Kong test server +- Production/default branch: not modified + +This record covers P1 / PR 1 from +`docs/21-unified-identity-federation-design.md`. LDAP, DingTalk, CAS, SAML, +SCIM, trusted gateway, Binding V2, profile-sync redesign, and runtime provider +plugins remain outside this PR. + +## Automated gates + +The following checks ran on the Hong Kong test server: + +| Gate | Result | +|---|---| +| `./mvnw -pl skillhub-app -am test -B` with Java 21 | 671 tests, 0 failures, 0 errors, 1 skipped | +| `pnpm run typecheck` | passed | +| `pnpm run lint` | passed | +| `pnpm run build` | passed; only the existing Vite chunk-size warning remained | +| `scripts/smoke-test.sh` against the isolated deployment | 21 passed, 0 failed | +| DCO | passed | +| CLA | passed | + +The server image used for pre-integration runtime validation was +`skillhub-server:identity-core-9818333c`, image ID +`sha256:a9055606ad2a551f12319e66f7056e4bda072a8e14a238882cdbdbeb871cf6c8`. +The clean PR branch was rebuilt from the latest `big-main`; its unified-identity +file tree is identical to the validated feature tree. + +## PostgreSQL and runtime scenarios + +All database scenarios used isolated PostgreSQL 16 and Redis 7 containers. +They did not connect to or modify the shared test-environment database. + +| Scenario | Observable result | +|---|---| +| Fresh migration | Flyway V44 applied successfully and created `identity_provider_state` | +| Fixed GitHub authority vector | `oauth2-github`, `https://github.com`, fingerprint `b2a93d58465e3de9e8b6cd127ba18425ae0f80c49c85f18f76086832923ca619`, state `READY` | +| Concurrent first pin | Two application instances converged to one READY row with the same fingerprint; no unique-constraint error | +| Legacy OAuth binding | Existing `identity_binding` row remained byte-for-byte equivalent while the provider moved through first pin to READY | +| Sticky mismatch | State remained `AUTHORITY_MISMATCH` across two application restarts; provider catalog was empty and the authorization route returned 503 | +| Same-authority recovery | SUPER_ADMIN recovery returned 200, changed the state to READY, wrote `PROVIDER_AUTHORITY_RECOVERED`, and updated the catalog without restart | +| Idempotent recovery | Repeating recovery returned `recovered=false` and did not append an audit record | +| Stale READY mismatch window | Recovery returned 409, persisted `AUTHORITY_MISMATCH`, retained the pinned authority/fingerprint, and wrote no recovery audit | +| Transaction rollback | A forced audit insert failure returned 500; the provider state update rolled back and no audit record was added | +| Unknown provider routes | Authorization and callback routes returned 403 without an upstream redirect | +| V43 to V44 upgrade | A database initialized by `v0.2.15` upgraded successfully and retained its legacy OAuth binding | +| Mixed-version and rollback | Current and `v0.2.15` servers were simultaneously healthy against the V44 database; the old provider endpoint returned 200 | +| Redis session compatibility | A local session created by `v0.2.15` was accepted by the current server for the same user | + +## Remaining integration gate + +After PR #643 is merged into `big-main`, build immutable Server/Web images from +the merge commit, deploy them to the shared test environment, and verify: + +1. health, login catalog, and local-password login through the configured test + domain; +2. unknown provider authorization/callback rejection; +3. V44 migration and READY provider state in the shared database; +4. existing Redis sessions and OAuth bindings; +5. recovery authorization and audit behavior; +6. logs contain no credentials or unexpected identity errors. + +Do not merge this work into `main` until the shared-environment gate is +recorded here. diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 033b0dd2..90962c11 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -6,13 +6,19 @@ PASS=0 FAIL=0 COOKIE_JAR="$(mktemp)" REGISTER_RESPONSE_FILE="$(mktemp)" +AUTH_METHODS_RESPONSE_FILE="$(mktemp)" +AUTH_PROVIDERS_RESPONSE_FILE="$(mktemp)" USERNAME="smoketest_$(date +%s)" EMAIL="${USERNAME}@example.com" PASSWORD="Smoke@2026" NEW_PASSWORD="Smoke@2027" cleanup() { - rm -f "$COOKIE_JAR" "$REGISTER_RESPONSE_FILE" + rm -f \ + "$COOKIE_JAR" \ + "$REGISTER_RESPONSE_FILE" \ + "$AUTH_METHODS_RESPONSE_FILE" \ + "$AUTH_PROVIDERS_RESPONSE_FILE" } trap cleanup EXIT @@ -40,6 +46,57 @@ check "Health endpoint" "$BASE_URL/actuator/health" "200" check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401" check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401" check "Auth required" "$BASE_URL/api/v1/auth/me" "401" +check "Unknown OAuth provider fails before upstream redirect" \ + "$BASE_URL/oauth2/authorization/__missing_provider__" "403" + +AUTH_METHODS_STATUS="$(curl --max-time 10 -s \ + -o "$AUTH_METHODS_RESPONSE_FILE" \ + -w "%{http_code}" \ + "$BASE_URL/api/v1/auth/methods" || true)" +AUTH_PROVIDERS_STATUS="$(curl --max-time 10 -s \ + -o "$AUTH_PROVIDERS_RESPONSE_FILE" \ + -w "%{http_code}" \ + "$BASE_URL/api/v1/auth/providers" || true)" +if [[ "$AUTH_METHODS_STATUS" == "200" \ + && "$AUTH_PROVIDERS_STATUS" == "200" ]] \ + && python3 - \ + "$AUTH_METHODS_RESPONSE_FILE" \ + "$AUTH_PROVIDERS_RESPONSE_FILE" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as response: + methods = json.load(response)["data"] +with open(sys.argv[2], encoding="utf-8") as response: + providers = json.load(response)["data"] + +method_ids = {method["id"] for method in methods} +oauth_methods = { + method["provider"]: method + for method in methods + if method["methodType"] == "OAUTH_REDIRECT" +} +oauth_providers = {provider["id"]: provider for provider in providers} + +valid = ( + "local-password" in method_ids + and oauth_methods.keys() == oauth_providers.keys() + and all( + method["actionUrl"].startswith( + f"/oauth2/authorization/{provider_code}" + ) + for provider_code, method in oauth_methods.items() + ) +) +raise SystemExit(0 if valid else 1) +PY +then + echo "PASS: Authentication catalog exposes only reconciled OAuth providers" + PASS=$((PASS + 1)) +else + echo "FAIL: Authentication catalog is inconsistent (methods HTTP $AUTH_METHODS_STATUS, providers HTTP $AUTH_PROVIDERS_STATUS)" + FAIL=$((FAIL + 1)) +fi curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1)" @@ -159,6 +216,18 @@ fi # Refresh CSRF after login ADMIN_CSRF="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$ADMIN_COOKIE_JAR" | tail -n 1)" +UNKNOWN_RECOVERY_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/admin/identity-providers/__missing_provider__/authority/recover" \ + -b "$ADMIN_COOKIE_JAR" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" || true)" +if [[ "$UNKNOWN_RECOVERY_STATUS" == "404" ]]; then + echo "PASS: Provider authority recovery rejects unknown provider without mutation (HTTP $UNKNOWN_RECOVERY_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Provider authority recovery unknown-provider guard (expected 404, got $UNKNOWN_RECOVERY_STATUS)" + FAIL=$((FAIL + 1)) +fi + # Exercise the administrator activation workflow over HTTP. The transactional # integration test covers the missing-membership precondition; this smoke path # verifies the deployed controller, security, persistence, and read model. diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/IdentityProviderAdminController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/IdentityProviderAdminController.java new file mode 100644 index 00000000..b98e1037 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/IdentityProviderAdminController.java @@ -0,0 +1,48 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.IdentityProviderAuthorityRecoveryResponse; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.IdentityProviderAdminAppService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Super-administrator operations for external identity providers. + */ +@RestController +@RequestMapping("/api/v1/admin/identity-providers") +public class IdentityProviderAdminController extends BaseApiController { + + private final IdentityProviderAdminAppService providerAdminAppService; + + public IdentityProviderAdminController( + IdentityProviderAdminAppService providerAdminAppService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.providerAdminAppService = providerAdminAppService; + } + + @PostMapping("/{providerCode}/authority/recover") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse + recoverSameAuthority( + @PathVariable String providerCode, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest request) { + return ok( + "response.success.updated", + providerAdminAppService.recoverSameAuthority( + providerCode, + principal.userId(), + AuditRequestContext.from(request))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityProviderAuthorityRecoveryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityProviderAuthorityRecoveryResponse.java new file mode 100644 index 00000000..7e9cdd1f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/IdentityProviderAuthorityRecoveryResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +/** + * Result of an administrative same-authority recovery operation. + */ +public record IdentityProviderAuthorityRecoveryResponse( + String providerCode, + boolean recovered, + String state +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java index 8c63cb9d..3e86289a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -2,6 +2,8 @@ package com.iflytek.skillhub.service; import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; import com.iflytek.skillhub.auth.direct.DirectAuthProvider; +import com.iflytek.skillhub.auth.identity.IdentityProviderCatalog; +import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod; import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport; import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; import com.iflytek.skillhub.config.DirectAuthProperties; @@ -12,8 +14,6 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Comparator; import java.util.List; -import java.util.Locale; -import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.stereotype.Service; /** @@ -23,18 +23,18 @@ import org.springframework.stereotype.Service; @Service public class AuthMethodCatalog { - private final OAuth2ClientProperties oAuth2ClientProperties; + private final IdentityProviderCatalog identityProviderCatalog; private final DirectAuthProperties directAuthProperties; private final AuthSessionBootstrapProperties sessionBootstrapProperties; private final List directAuthProviders; private final List passiveSessionAuthenticators; - public AuthMethodCatalog(OAuth2ClientProperties oAuth2ClientProperties, + public AuthMethodCatalog(IdentityProviderCatalog identityProviderCatalog, DirectAuthProperties directAuthProperties, AuthSessionBootstrapProperties sessionBootstrapProperties, List directAuthProviders, List passiveSessionAuthenticators) { - this.oAuth2ClientProperties = oAuth2ClientProperties; + this.identityProviderCatalog = identityProviderCatalog; this.directAuthProperties = directAuthProperties; this.sessionBootstrapProperties = sessionBootstrapProperties; this.directAuthProviders = directAuthProviders; @@ -43,30 +43,16 @@ public class AuthMethodCatalog { public List listOAuthProviders(String returnTo) { String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); - return new ArrayList<>(oAuth2ClientProperties.getRegistration().entrySet().stream() - .filter(entry -> isValidOAuthProvider(entry.getValue())) - .sorted(Comparator.comparing(entry -> entry.getKey())) - .map(entry -> new AuthProviderResponse( - entry.getKey(), - entry.getValue().getClientName() != null && !entry.getValue().getClientName().isBlank() - ? entry.getValue().getClientName() - : entry.getKey(), - buildAuthorizationUrl(entry.getKey(), sanitizedReturnTo) + return new ArrayList<>(identityProviderCatalog.listReadyProviders().stream() + .sorted(Comparator.comparing(IdentityProviderLoginMethod::providerCode)) + .map(provider -> new AuthProviderResponse( + provider.providerCode(), + provider.displayName(), + buildAuthorizationUrl(provider.providerCode(), sanitizedReturnTo) )) .toList()); } - /** - * Checks whether an OAuth provider has a non-empty, non-placeholder client ID. - */ - private boolean isValidOAuthProvider(OAuth2ClientProperties.Registration registration) { - String clientId = registration.getClientId(); - if (clientId == null || clientId.isBlank()) { - return false; - } - return !clientId.toLowerCase(Locale.ROOT).contains("placeholder"); - } - public List listMethods(String returnTo) { String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); List methods = new ArrayList<>(); @@ -79,17 +65,14 @@ public class AuthMethodCatalog { "/api/v1/auth/local/login" )); - oAuth2ClientProperties.getRegistration().entrySet().stream() - .filter(entry -> isValidOAuthProvider(entry.getValue())) - .sorted(Comparator.comparing(entry -> entry.getKey())) - .forEach(entry -> methods.add(new AuthMethodResponse( - "oauth-" + entry.getKey(), + identityProviderCatalog.listReadyProviders().stream() + .sorted(Comparator.comparing(IdentityProviderLoginMethod::providerCode)) + .forEach(provider -> methods.add(new AuthMethodResponse( + "oauth-" + provider.providerCode(), "OAUTH_REDIRECT", - entry.getKey(), - entry.getValue().getClientName() != null && !entry.getValue().getClientName().isBlank() - ? entry.getValue().getClientName() - : entry.getKey(), - buildAuthorizationUrl(entry.getKey(), sanitizedReturnTo) + provider.providerCode(), + provider.displayName(), + buildAuthorizationUrl(provider.providerCode(), sanitizedReturnTo) ))); if (directAuthProperties.isEnabled()) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityProviderAdminAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityProviderAdminAppService.java new file mode 100644 index 00000000..b00475d0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/IdentityProviderAdminAppService.java @@ -0,0 +1,50 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.identity.IdentityProviderAuthorityOperations; +import com.iflytek.skillhub.auth.identity.IdentityProviderAuthorityRecoveryContext; +import com.iflytek.skillhub.auth.identity.IdentityProviderAuthorityRecoveryResult; +import com.iflytek.skillhub.dto.IdentityProviderAuthorityRecoveryResponse; +import org.slf4j.MDC; +import org.springframework.stereotype.Service; + +/** + * Orchestrates authenticated identity-provider administration use cases. + */ +@Service +public class IdentityProviderAdminAppService { + + private static final String REQUEST_ID_MDC_KEY = "requestId"; + + private final IdentityProviderAuthorityOperations authorityOperations; + + public IdentityProviderAdminAppService( + IdentityProviderAuthorityOperations authorityOperations) { + this.authorityOperations = authorityOperations; + } + + public IdentityProviderAuthorityRecoveryResponse recoverSameAuthority( + String providerCode, + String actorUserId, + AuditRequestContext auditContext) { + IdentityProviderAuthorityRecoveryResult result = + authorityOperations.recoverSameAuthority( + providerCode, + new IdentityProviderAuthorityRecoveryContext( + actorUserId, + bounded( + MDC.get(REQUEST_ID_MDC_KEY), + 64), + bounded(auditContext.clientIp(), 64), + bounded(auditContext.userAgent(), 512))); + return new IdentityProviderAuthorityRecoveryResponse( + result.providerCode(), + result.recovered(), + result.state()); + } + + private String bounded(String value, int maximum) { + return value == null || value.length() > maximum + ? null + : value; + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V44__identity_provider_authority_state.sql b/server/skillhub-app/src/main/resources/db/migration/V44__identity_provider_authority_state.sql new file mode 100644 index 00000000..30192e4a --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V44__identity_provider_authority_state.sql @@ -0,0 +1,46 @@ +CREATE TABLE identity_provider_state ( + provider_code VARCHAR(64) PRIMARY KEY, + protocol VARCHAR(32) NOT NULL, + authority VARCHAR(512), + authority_fingerprint VARCHAR(64), + state VARCHAR(32) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMPTZ, + CONSTRAINT chk_identity_provider_authority_pair + CHECK ( + (authority IS NULL AND authority_fingerprint IS NULL) + OR + (authority IS NOT NULL AND authority_fingerprint IS NOT NULL) + ), + CONSTRAINT chk_identity_provider_fingerprint + CHECK ( + authority_fingerprint IS NULL + OR authority_fingerprint ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT chk_identity_provider_state + CHECK ( + state IN ( + 'READY', + 'DISABLED', + 'MISCONFIGURED', + 'DEGRADED', + 'AUTHORITY_MISMATCH', + 'LEGACY_UNPINNED' + ) + ), + CONSTRAINT chk_identity_provider_state_authority + CHECK ( + (state = 'LEGACY_UNPINNED' + AND authority IS NULL + AND authority_fingerprint IS NULL) + OR + (state IN ('READY', 'DEGRADED', 'AUTHORITY_MISMATCH') + AND authority IS NOT NULL + AND authority_fingerprint IS NOT NULL) + OR + state IN ('DISABLED', 'MISCONFIGURED') + ) +); + +CREATE INDEX idx_identity_provider_state_status + ON identity_provider_state(state); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index c7d873e4..e00af5a4 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -39,6 +39,7 @@ error.auth.local.notEnabled=Local account login is not enabled for this user error.auth.local.accountDisabled=This account has been disabled error.auth.local.accountPending=This account is pending activation error.auth.local.accountMerged=This account has been merged and can no longer be used to log in +error.auth.local.systemAccount=System accounts cannot use interactive login error.auth.local.locked=Too many failed attempts. Please try again in {0} minute(s) error.auth.login.throttled=Too many login attempts. Please try again in {0} minute(s) error.auth.direct.disabled=Direct authentication compatibility is disabled @@ -182,3 +183,6 @@ promotion.status.invalid=Unsupported promotion status: {0} promotion.sort.field.invalid=Unsupported promotion sort field: {0} promotion.sort.direction.invalid=Unsupported promotion sort direction: {0} promotion.sort.pending_unsupported=Pending promotion requests do not support reviewed-time sorting +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 diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 82ec6001..73898cf0 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -39,6 +39,7 @@ error.auth.local.notEnabled=当前用户未启用本地账号登录 error.auth.local.accountDisabled=该账号已被禁用 error.auth.local.accountPending=该账号尚未激活 error.auth.local.accountMerged=该账号已合并,不能再用于登录 +error.auth.local.systemAccount=系统账号不能用于交互式登录 error.auth.local.locked=连续失败次数过多,请在 {0} 分钟后重试 error.auth.login.throttled=登录尝试过于频繁,请在 {0} 分钟后重试 error.auth.direct.disabled=直连认证兼容层未启用 @@ -182,3 +183,6 @@ promotion.status.invalid=不支持的提升审核状态:{0} promotion.sort.field.invalid=不支持的提升审核排序字段:{0} promotion.sort.direction.invalid=不支持的提升审核排序方向:{0} promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间排序 +error.auth.provider.notFound=未找到身份提供方 +error.auth.provider.authorityRecoveryMismatch=身份提供方配置仍指向不同的身份域 +error.auth.provider.authorityRecoveryUnavailable=身份提供方当前状态不允许恢复身份域 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index 8e4118c6..b43ee18c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.controller; +import com.iflytek.skillhub.auth.identity.IdentityProviderCatalog; +import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.local.LocalCredentialRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; @@ -7,6 +9,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.security.AuthFailureThrottleService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -68,6 +71,18 @@ class AuthControllerTest { @MockBean private LocalCredentialRepository localCredentialRepository; + @MockBean + private IdentityProviderCatalog identityProviderCatalog; + + @BeforeEach + void setUpReadyIdentityProviders() { + given(identityProviderCatalog.listReadyProviders()) + .willReturn(List.of(new IdentityProviderLoginMethod( + "github", + "GitHub" + ))); + } + @Test void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/auth/me")) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/IdentityProviderAdminControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/IdentityProviderAdminControllerTest.java new file mode 100644 index 00000000..1fd7144e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/IdentityProviderAdminControllerTest.java @@ -0,0 +1,116 @@ +package com.iflytek.skillhub.controller.admin; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.dto.IdentityProviderAuthorityRecoveryResponse; +import com.iflytek.skillhub.service.IdentityProviderAdminAppService; +import java.util.List; +import java.util.Set; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class IdentityProviderAdminControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private IdentityProviderAdminAppService providerAdminAppService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void superAdminCanRecoverSameAuthority() throws Exception { + when(providerAdminAppService.recoverSameAuthority( + eq("github"), + eq("admin"), + any())).thenReturn( + new IdentityProviderAuthorityRecoveryResponse( + "github", + true, + "READY")); + + mockMvc.perform(post( + "/api/v1/admin/identity-providers/github/authority/recover") + .with(authentication(superAdminAuth())) + .with(csrf()) + .header("User-Agent", "SkillHub Browser")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.providerCode") + .value("github")) + .andExpect(jsonPath("$.data.recovered").value(true)) + .andExpect(jsonPath("$.data.state").value("READY")); + + verify(providerAdminAppService).recoverSameAuthority( + eq("github"), + eq("admin"), + any()); + } + + @Test + void nonSuperAdminCannotRecoverProviderAuthority() + throws Exception { + mockMvc.perform(post( + "/api/v1/admin/identity-providers/github/authority/recover") + .with(authentication(userAdminAuth())) + .with(csrf())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + private static UsernamePasswordAuthenticationToken + superAdminAuth() { + return principalAuthentication( + "admin", + "SUPER_ADMIN"); + } + + private static UsernamePasswordAuthenticationToken + userAdminAuth() { + return principalAuthentication( + "user-admin", + "USER_ADMIN"); + } + + private static UsernamePasswordAuthenticationToken principalAuthentication( + String userId, + String role) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + null, + "local", + Set.of(role)); + return new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_" + role))); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java index e9ef372f..4b22db46 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java @@ -6,27 +6,24 @@ import static org.mockito.Mockito.mock; import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; import com.iflytek.skillhub.auth.direct.DirectAuthProvider; import com.iflytek.skillhub.auth.direct.DirectAuthRequest; +import com.iflytek.skillhub.auth.identity.IdentityProviderCatalog; +import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.config.AuthSessionBootstrapProperties; import com.iflytek.skillhub.config.DirectAuthProperties; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; class AuthMethodCatalogTest { @Test - void catalogsShouldHideEmptyAndPlaceholderOAuthProviders() { - OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties(); - oauthProperties.getRegistration().put("valid", registration("production-client", "Valid")); - oauthProperties.getRegistration().put("missing", registration(null, "Missing")); - oauthProperties.getRegistration().put("blank", registration(" ", "Blank")); - oauthProperties.getRegistration().put("placeholder", registration("PLACEHOLDER", "Placeholder")); - oauthProperties.getRegistration().put("local", registration("local-placeholder", "Local")); + void catalogsOnlyProvidersApprovedByTheIdentityCore() { + IdentityProviderCatalog identityProviderCatalog = + () -> List.of(new IdentityProviderLoginMethod("valid", "Valid")); AuthMethodCatalog catalog = new AuthMethodCatalog( - oauthProperties, + identityProviderCatalog, new DirectAuthProperties(), new AuthSessionBootstrapProperties(), List.of(), @@ -43,7 +40,6 @@ class AuthMethodCatalogTest { @Test void listMethodsShouldUseProviderDisplayNamesForCompatibleAuthMethods() { - OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties(); DirectAuthProperties directAuthProperties = new DirectAuthProperties(); directAuthProperties.setEnabled(true); AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties(); @@ -84,7 +80,7 @@ class AuthMethodCatalogTest { }; AuthMethodCatalog catalog = new AuthMethodCatalog( - oauthProperties, + List::of, directAuthProperties, bootstrapProperties, List.of(directProvider), @@ -102,7 +98,6 @@ class AuthMethodCatalogTest { @Test void listMethodsShouldFallBackToProviderCodeWhenDisplayNameIsNotOverridden() { - OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties(); DirectAuthProperties directAuthProperties = new DirectAuthProperties(); directAuthProperties.setEnabled(true); AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties(); @@ -133,7 +128,7 @@ class AuthMethodCatalogTest { }; AuthMethodCatalog catalog = new AuthMethodCatalog( - oauthProperties, + List::of, directAuthProperties, bootstrapProperties, List.of(directProvider), @@ -148,10 +143,4 @@ class AuthMethodCatalogTest { ); } - private static OAuth2ClientProperties.Registration registration(String clientId, String clientName) { - OAuth2ClientProperties.Registration registration = new OAuth2ClientProperties.Registration(); - registration.setClientId(clientId); - registration.setClientName(clientName); - return registration; - } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityProviderAdminAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityProviderAdminAppServiceTest.java new file mode 100644 index 00000000..9cf36559 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/IdentityProviderAdminAppServiceTest.java @@ -0,0 +1,62 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.identity.IdentityProviderAuthorityOperations; +import com.iflytek.skillhub.auth.identity.IdentityProviderAuthorityRecoveryContext; +import com.iflytek.skillhub.auth.identity.IdentityProviderAuthorityRecoveryResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.slf4j.MDC; + +class IdentityProviderAdminAppServiceTest { + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void forwardsAuthenticatedOperatorAndRequestAuditMetadata() { + IdentityProviderAuthorityOperations operations = + mock(IdentityProviderAuthorityOperations.class); + when(operations.recoverSameAuthority( + eq("github"), + org.mockito.ArgumentMatchers.any())) + .thenReturn(new IdentityProviderAuthorityRecoveryResult( + "github", + true, + "READY")); + IdentityProviderAdminAppService service = + new IdentityProviderAdminAppService(operations); + MDC.put("requestId", "req-123"); + + var response = service.recoverSameAuthority( + "github", + "admin", + new AuditRequestContext( + "203.0.113.9", + "SkillHub Browser")); + + ArgumentCaptor context = + ArgumentCaptor.forClass( + IdentityProviderAuthorityRecoveryContext.class); + verify(operations).recoverSameAuthority( + eq("github"), + context.capture()); + assertThat(context.getValue()).isEqualTo( + new IdentityProviderAuthorityRecoveryContext( + "admin", + "req-123", + "203.0.113.9", + "SkillHub Browser")); + assertThat(response.providerCode()).isEqualTo("github"); + assertThat(response.recovered()).isTrue(); + assertThat(response.state()).isEqualTo("READY"); + } +} 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 8c2ff2dc..777745b3 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 @@ -4,7 +4,9 @@ import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; import com.iflytek.skillhub.auth.oauth.CustomOidcUserService; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; +import com.iflytek.skillhub.auth.oauth.IdentityProviderRouteReadinessFilter; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; +import com.iflytek.skillhub.auth.identity.IdentityProviderReadinessService; import com.iflytek.skillhub.auth.mock.MockAuthFilter; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; @@ -31,6 +33,8 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -66,6 +70,8 @@ public class SecurityConfig { private final AccessDeniedHandler apiAccessDeniedHandler; private final ObjectProvider mockAuthFilterProvider; private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry; + private final ClientRegistrationRepository clientRegistrationRepository; + private final IdentityProviderReadinessService providerReadinessService; public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomOidcUserService customOidcUserService, @@ -77,7 +83,9 @@ public class SecurityConfig { AuthenticationEntryPoint apiAuthenticationEntryPoint, AccessDeniedHandler apiAccessDeniedHandler, ObjectProvider mockAuthFilterProvider, - RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) { + RouteSecurityPolicyRegistry routeSecurityPolicyRegistry, + ClientRegistrationRepository clientRegistrationRepository, + IdentityProviderReadinessService providerReadinessService) { this.customOAuth2UserService = customOAuth2UserService; this.customOidcUserService = customOidcUserService; this.authorizationRequestResolver = authorizationRequestResolver; @@ -89,6 +97,8 @@ public class SecurityConfig { this.apiAccessDeniedHandler = apiAccessDeniedHandler; this.mockAuthFilterProvider = mockAuthFilterProvider; this.routeSecurityPolicyRegistry = routeSecurityPolicyRegistry; + this.clientRegistrationRepository = clientRegistrationRepository; + this.providerReadinessService = providerReadinessService; } /** @@ -156,6 +166,11 @@ public class SecurityConfig { .invalidateHttpSession(true) .deleteCookies("SESSION") ) + .addFilterBefore( + new IdentityProviderRouteReadinessFilter( + clientRegistrationRepository, + providerReadinessService), + OAuth2AuthorizationRequestRedirectFilter.class) .addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAfter(apiTokenScopeFilter, ApiTokenAuthenticationFilter.class); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AccountLoginDecision.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AccountLoginDecision.java new file mode 100644 index 00000000..cfc98288 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AccountLoginDecision.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.auth.identity; + +/** + * Shared interactive-login account-state decision. + */ +public enum AccountLoginDecision { + ALLOWED, + PENDING, + DISABLED, + MERGED, + SYSTEM_ACCOUNT +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AccountLoginGuard.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AccountLoginGuard.java new file mode 100644 index 00000000..9a9e3924 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AccountLoginGuard.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.util.Objects; +import org.springframework.stereotype.Service; + +/** + * Central account-state guard for interactive local and federated login. + */ +@Service +public class AccountLoginGuard { + + public AccountLoginDecision evaluateInteractive(UserAccount user) { + Objects.requireNonNull(user, "user"); + if (user.isSystemAccount()) { + return AccountLoginDecision.SYSTEM_ACCOUNT; + } + return switch (user.getStatus()) { + case ACTIVE -> AccountLoginDecision.ALLOWED; + case PENDING -> AccountLoginDecision.PENDING; + case DISABLED -> AccountLoginDecision.DISABLED; + case MERGED -> AccountLoginDecision.MERGED; + }; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AuthenticationEvidence.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AuthenticationEvidence.java new file mode 100644 index 00000000..bd8278b4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AuthenticationEvidence.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.auth.identity; + +import java.time.Instant; +import java.util.Objects; +import java.util.Set; + +record AuthenticationEvidence( + String protocol, + Instant authenticatedAt, + Set authenticationMethods +) { + AuthenticationEvidence { + Objects.requireNonNull(protocol, "protocol"); + Objects.requireNonNull(authenticatedAt, "authenticatedAt"); + Objects.requireNonNull(authenticationMethods, "authenticationMethods"); + authenticationMethods = Set.copyOf(authenticationMethods); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AuthorityLockEvaluation.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AuthorityLockEvaluation.java new file mode 100644 index 00000000..0af7d528 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/AuthorityLockEvaluation.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.identity; + +record AuthorityLockEvaluation( + IdentityProviderStatus state, + String persistedFingerprint +) { + boolean ready() { + return state == IdentityProviderStatus.READY; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginService.java new file mode 100644 index 00000000..f815af81 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginService.java @@ -0,0 +1,73 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.policy.AccessDecision; +import com.iflytek.skillhub.auth.policy.AccessPolicy; +import com.iflytek.skillhub.auth.policy.IdentityAccessContext; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.util.Objects; +import org.springframework.stereotype.Service; + +@Service +class DefaultExternalIdentityLoginService + implements ExternalIdentityLoginService { + + private final TrustedProviderDescriptorSource descriptorSource; + private final ProviderAuthorityLockService authorityLockService; + private final IdentityAssertionFactory assertionFactory; + private final AccessPolicy accessPolicy; + private final IdentityResolutionTransaction resolutionTransaction; + + DefaultExternalIdentityLoginService( + TrustedProviderDescriptorSource descriptorSource, + ProviderAuthorityLockService authorityLockService, + IdentityAssertionFactory assertionFactory, + AccessPolicy accessPolicy, + IdentityResolutionTransaction resolutionTransaction) { + this.descriptorSource = descriptorSource; + this.authorityLockService = authorityLockService; + this.assertionFactory = assertionFactory; + this.accessPolicy = accessPolicy; + this.resolutionTransaction = resolutionTransaction; + } + + @Override + public IdentityLoginOutcome authenticate( + ResolvedProviderHandle provider, + ProviderAuthenticationResult result, + IdentityLoginContext context) { + Objects.requireNonNull(provider, "provider"); + Objects.requireNonNull(result, "result"); + Objects.requireNonNull(context, "context"); + + ProviderDescriptor descriptor = descriptorSource.require(provider); + authorityLockService.requirePinnedAuthority(descriptor); + IdentityAssertion assertion = + assertionFactory.create(descriptor, result); + AccessDecision decision = accessPolicy.evaluate( + toAccessContext(assertion, context)); + if (decision == AccessDecision.DENY) { + throw new IdentityCoreException( + IdentityFailureCode.ACCESS_DENIED); + } + + UserStatus initialStatus = + decision == AccessDecision.PENDING_APPROVAL + ? UserStatus.PENDING + : UserStatus.ACTIVE; + return resolutionTransaction.resolve(assertion, initialStatus); + } + + private IdentityAccessContext toAccessContext( + IdentityAssertion assertion, + IdentityLoginContext context) { + return new IdentityAccessContext( + assertion.provider().providerCode(), + assertion.primarySubject().type(), + assertion.primarySubject().value(), + assertion.profile().email().map(EmailClaim::value), + assertion.profile().email() + .map(EmailClaim::assurance) + .orElse(EmailAssurance.UNVERIFIED), + context); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderAuthorityOperations.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderAuthorityOperations.java new file mode 100644 index 00000000..f687cc92 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderAuthorityOperations.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import java.util.Objects; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Service +class DefaultIdentityProviderAuthorityOperations + implements IdentityProviderAuthorityOperations { + + private final TrustedProviderDescriptorSource descriptorSource; + private final ProviderAuthorityLockService authorityLockService; + + DefaultIdentityProviderAuthorityOperations( + TrustedProviderDescriptorSource descriptorSource, + ProviderAuthorityLockService authorityLockService) { + this.descriptorSource = descriptorSource; + this.authorityLockService = authorityLockService; + } + + @Override + public IdentityProviderAuthorityRecoveryResult recoverSameAuthority( + String providerCode, + IdentityProviderAuthorityRecoveryContext context) { + Objects.requireNonNull(providerCode, "providerCode"); + Objects.requireNonNull(context, "context"); + ProviderDescriptor descriptor = descriptorSource + .enabledDescriptors() + .stream() + .filter(candidate -> candidate.providerCode() + .equals(providerCode)) + .findFirst() + .orElseThrow(() -> new AuthFlowException( + HttpStatus.NOT_FOUND, + "error.auth.provider.notFound")); + + SameAuthorityRecoveryEvaluation recovery = + authorityLockService.recoverSameAuthority( + descriptor, + context); + AuthorityLockEvaluation authority = recovery.authority(); + if (!authority.ready()) { + String messageCode = authority.state() + == IdentityProviderStatus.AUTHORITY_MISMATCH + ? "error.auth.provider.authorityRecoveryMismatch" + : "error.auth.provider.authorityRecoveryUnavailable"; + throw new AuthFlowException( + HttpStatus.CONFLICT, + messageCode); + } + return new IdentityProviderAuthorityRecoveryResult( + descriptor.providerCode(), + recovery.recovered(), + authority.state().name()); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderReadinessService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderReadinessService.java new file mode 100644 index 00000000..b60fa527 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderReadinessService.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.auth.identity; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.stereotype.Service; + +@Service +class DefaultIdentityProviderReadinessService + implements IdentityProviderReadinessService { + + private final TrustedProviderRouteResolver routeResolver; + private final TrustedProviderDescriptorSource descriptorSource; + private final ProviderAuthorityLockService authorityLockService; + + DefaultIdentityProviderReadinessService( + TrustedProviderRouteResolver routeResolver, + TrustedProviderDescriptorSource descriptorSource, + ProviderAuthorityLockService authorityLockService) { + this.routeResolver = routeResolver; + this.descriptorSource = descriptorSource; + this.authorityLockService = authorityLockService; + } + + @Override + public void requireReady(ClientRegistration registration) { + ResolvedProviderHandle handle = routeResolver.resolve(registration); + ProviderDescriptor descriptor = descriptorSource.require(handle); + authorityLockService.requirePinnedAuthority(descriptor); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultResolvedProviderHandle.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultResolvedProviderHandle.java new file mode 100644 index 00000000..19eac459 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultResolvedProviderHandle.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +record DefaultResolvedProviderHandle( + String providerCode +) implements ResolvedProviderHandle { + + DefaultResolvedProviderHandle { + Objects.requireNonNull(providerCode, "providerCode"); + if (providerCode.isBlank()) { + throw new IllegalArgumentException( + "Provider code must not be blank"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/EmailAssurance.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/EmailAssurance.java new file mode 100644 index 00000000..e9652203 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/EmailAssurance.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.auth.identity; + +/** + * Assurance retained by the identity core after clamping adapter facts to the + * trusted provider descriptor. + */ +public enum EmailAssurance { + UNVERIFIED, + PROVIDER_ASSERTED, + VERIFIED, + AUTHORITATIVE; + + EmailAssurance clampTo(EmailAssurance maximum) { + return ordinal() <= maximum.ordinal() ? this : maximum; + } + + public boolean isVerifiedOrAuthoritative() { + return this == VERIFIED || this == AUTHORITATIVE; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/EmailClaim.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/EmailClaim.java new file mode 100644 index 00000000..40792fcb --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/EmailClaim.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +record EmailClaim( + String value, + EmailAssurance assurance +) { + EmailClaim { + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(assurance, "assurance"); + if (value.isBlank() || value.length() > 256 || !value.contains("@")) { + throw new IllegalArgumentException("Invalid email claim"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityLoginService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityLoginService.java new file mode 100644 index 00000000..0d76bdf7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalIdentityLoginService.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.identity; + +/** + * The only application-facing facade for converting externally authenticated + * provider facts into a platform login outcome. + */ +public interface ExternalIdentityLoginService { + + IdentityLoginOutcome authenticate( + ResolvedProviderHandle provider, + ProviderAuthenticationResult result, + IdentityLoginContext context); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalProfile.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalProfile.java new file mode 100644 index 00000000..a3e0c551 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalProfile.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.auth.identity; + +import java.net.URI; +import java.util.Objects; +import java.util.Optional; + +record ExternalProfile( + String displayName, + Optional email, + Optional avatarUrl +) { + ExternalProfile { + Objects.requireNonNull(displayName, "displayName"); + Objects.requireNonNull(email, "email"); + Objects.requireNonNull(avatarUrl, "avatarUrl"); + if (displayName.isBlank() || displayName.length() > 128) { + throw new IllegalArgumentException("Invalid external display name"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalSubject.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalSubject.java new file mode 100644 index 00000000..8950d22e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ExternalSubject.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +record ExternalSubject( + String type, + String value +) { + ExternalSubject { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(value, "value"); + if (type.isBlank() || type.length() > 64) { + throw new IllegalArgumentException("Invalid external subject type"); + } + if (value.isBlank() || value.length() > ProviderAssertionLimits.MAX_SUBJECT_VALUE_LENGTH) { + throw new IllegalArgumentException("Invalid external subject value"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityAssertion.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityAssertion.java new file mode 100644 index 00000000..2e110883 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityAssertion.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +record IdentityAssertion( + ProviderReference provider, + ExternalSubject primarySubject, + Set alternateSubjects, + ExternalProfile profile, + Map> mappedAttributes, + AuthenticationEvidence evidence +) { + IdentityAssertion { + Objects.requireNonNull(provider, "provider"); + Objects.requireNonNull(primarySubject, "primarySubject"); + Objects.requireNonNull(alternateSubjects, "alternateSubjects"); + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(mappedAttributes, "mappedAttributes"); + Objects.requireNonNull(evidence, "evidence"); + + alternateSubjects = Set.copyOf(alternateSubjects); + LinkedHashMap> copied = new LinkedHashMap<>(); + mappedAttributes.forEach((key, values) -> copied.put(key, List.copyOf(values))); + mappedAttributes = Map.copyOf(copied); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactory.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactory.java new file mode 100644 index 00000000..219f71fe --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactory.java @@ -0,0 +1,154 @@ +package com.iflytek.skillhub.auth.identity; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.springframework.stereotype.Component; + +@Component +final class IdentityAssertionFactory { + + IdentityAssertion create( + ProviderDescriptor descriptor, + ProviderAuthenticationResult result) { + if (!descriptor.protocol().equals(result.evidence().protocol())) { + throw invalidAssertion(); + } + validatePayload(result); + if (!result.alternateSubjects().isEmpty()) { + throw invalidAssertion(); + } + + SubjectCandidate primary = result.primarySubject(); + if (!descriptor.primarySubjectType().equals(primary.type()) + || !descriptor.allowedSubjectTypes().contains(primary.type())) { + throw invalidAssertion(); + } + + String canonicalValue = descriptor.subjectCanonicalizer() + .canonicalize(primary.value()); + ExternalSubject primarySubject = + new ExternalSubject(primary.type(), canonicalValue); + ExternalProfile profile = createProfile(descriptor, result, primarySubject); + AuthenticationEvidence evidence = new AuthenticationEvidence( + descriptor.protocol(), + result.evidence().authenticatedAt(), + result.evidence().authenticationMethods()); + + return new IdentityAssertion( + new ProviderReference( + descriptor.providerCode(), + descriptor.protocol(), + descriptor.canonicalAuthority()), + primarySubject, + Set.of(), + profile, + Map.of(), + evidence); + } + + private ExternalProfile createProfile( + ProviderDescriptor descriptor, + ProviderAuthenticationResult result, + ExternalSubject primarySubject) { + String displayName = firstValue( + result.attributes(), descriptor.displayNameAttributes()) + .map(ProviderAttributeValue::value) + .filter(value -> !value.isBlank() && value.length() <= 128) + .orElseGet(() -> { + if (primarySubject.value().length() > 128) { + throw invalidAssertion(); + } + return primarySubject.value(); + }); + + Optional email = firstValue( + result.attributes(), descriptor.emailAttributes()) + .filter(value -> !value.value().isBlank()) + .map(value -> new EmailClaim( + value.value(), + toEmailAssurance(value.trust()) + .clampTo(descriptor.emailAssuranceLimit()))); + + Optional avatarUrl = firstValue( + result.attributes(), descriptor.avatarAttributes()) + .filter(value -> !value.value().isBlank()) + .map(ProviderAttributeValue::value) + .map(this::parseAvatarUri); + + return new ExternalProfile(displayName, email, avatarUrl); + } + + private Optional firstValue( + Map> attributes, + List trustedAttributeOrder) { + for (String attribute : trustedAttributeOrder) { + List values = attributes.get(attribute); + if (values != null && !values.isEmpty()) { + return Optional.of(values.getFirst()); + } + } + return Optional.empty(); + } + + private URI parseAvatarUri(String value) { + try { + URI uri = new URI(value); + if (!uri.isAbsolute() + || (!"https".equalsIgnoreCase(uri.getScheme()) + && !"http".equalsIgnoreCase(uri.getScheme()))) { + throw invalidAssertion(); + } + return uri; + } catch (URISyntaxException exception) { + throw new IdentityCoreException( + IdentityFailureCode.INVALID_IDENTITY_ASSERTION, + exception); + } + } + + private void validatePayload(ProviderAuthenticationResult result) { + if (result.attributes().size() > ProviderAssertionLimits.MAX_ATTRIBUTE_COUNT) { + throw invalidAssertion(); + } + int totalLength = result.primarySubject().type().length() + + result.primarySubject().value().length(); + for (SubjectCandidate alternate : result.alternateSubjects()) { + totalLength += alternate.type().length() + alternate.value().length(); + } + for (Map.Entry> entry + : result.attributes().entrySet()) { + if (entry.getValue().size() + > ProviderAssertionLimits.MAX_VALUES_PER_ATTRIBUTE) { + throw invalidAssertion(); + } + totalLength += entry.getKey().length(); + for (ProviderAttributeValue value : entry.getValue()) { + if (value.value().length() + > ProviderAssertionLimits.MAX_ATTRIBUTE_VALUE_LENGTH) { + throw invalidAssertion(); + } + totalLength += value.value().length(); + } + } + if (totalLength > ProviderAssertionLimits.MAX_TOTAL_PAYLOAD_LENGTH) { + throw invalidAssertion(); + } + } + + private EmailAssurance toEmailAssurance(ProviderAttributeTrust trust) { + return switch (trust) { + case UNVERIFIED -> EmailAssurance.UNVERIFIED; + case ASSERTED -> EmailAssurance.PROVIDER_ASSERTED; + case VERIFIED -> EmailAssurance.VERIFIED; + }; + } + + private IdentityCoreException invalidAssertion() { + return new IdentityCoreException( + IdentityFailureCode.INVALID_IDENTITY_ASSERTION); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java deleted file mode 100644 index 49fa3dc7..00000000 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.iflytek.skillhub.auth.identity; - -import com.iflytek.skillhub.auth.entity.IdentityBinding; -import com.iflytek.skillhub.auth.oauth.AccountDisabledException; -import com.iflytek.skillhub.auth.oauth.AccountMergedException; -import com.iflytek.skillhub.auth.oauth.AccountPendingException; -import com.iflytek.skillhub.auth.oauth.OAuthClaims; -import com.iflytek.skillhub.auth.oauth.SystemAccountLoginException; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; -import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; -import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; -import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; -import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; -import com.iflytek.skillhub.domain.user.UserStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.util.UUID; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * Resolves external OAuth identities to platform users, creating or updating - * bindings and user records as needed. - */ -@Service -public class IdentityBindingService { - - private final IdentityBindingRepository bindingRepo; - private final UserAccountRepository userRepo; - private final UserRoleBindingRepository roleBindingRepo; - private final GlobalNamespaceMembershipService globalNamespaceMembershipService; - - public IdentityBindingService(IdentityBindingRepository bindingRepo, - UserAccountRepository userRepo, - UserRoleBindingRepository roleBindingRepo, - GlobalNamespaceMembershipService globalNamespaceMembershipService) { - this.bindingRepo = bindingRepo; - this.userRepo = userRepo; - this.roleBindingRepo = roleBindingRepo; - this.globalNamespaceMembershipService = globalNamespaceMembershipService; - } - - @Transactional - public PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStatus) { - IdentityBinding binding = bindingRepo - .findByProviderCodeAndSubject(claims.provider(), claims.subject()) - .orElse(null); - - UserAccount user; - if (binding != null) { - user = userRepo.findById(binding.getUserId()) - .orElseThrow(() -> new IllegalStateException("User not found for binding")); - ensureExternalLoginAllowed(user); - user.setDisplayName(claims.providerLogin()); - if (trustedEmail(claims) != null) user.setEmail(claims.email()); - if (claims.extra().get("avatar_url") != null) { - user.setAvatarUrl((String) claims.extra().get("avatar_url")); - } - user = userRepo.save(user); - } else { - user = new UserAccount( - "usr_" + UUID.randomUUID(), - claims.providerLogin(), - trustedEmail(claims), - (String) claims.extra().get("avatar_url") - ); - user.setStatus(initialStatus); - user = userRepo.save(user); - if (initialStatus == UserStatus.ACTIVE) { - globalNamespaceMembershipService.ensureMember(user.getId()); - } - - binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin()); - bindingRepo.save(binding); - } - - ensureExternalLoginAllowed(user); - - Set roles = roleBindingRepo.findByUserId(user.getId()).stream() - .map(rb -> rb.getRole().getCode()) - .collect(Collectors.toSet()); - roles = PlatformRoleDefaults.withDefaultUserRole(roles); - - return new PlatformPrincipal( - user.getId(), user.getDisplayName(), user.getEmail(), - user.getAvatarUrl(), claims.provider(), roles - ); - } - - @Transactional - public void createPendingUserIfAbsent(OAuthClaims claims) { - IdentityBinding existingBinding = bindingRepo - .findByProviderCodeAndSubject(claims.provider(), claims.subject()) - .orElse(null); - if (existingBinding != null) { - UserAccount existingUser = userRepo.findById(existingBinding.getUserId()) - .orElseThrow(() -> new IllegalStateException("User not found for binding")); - ensureExternalLoginAllowed(existingUser); - throw new AccountPendingException(); - } - - UserAccount user = new UserAccount( - "usr_" + UUID.randomUUID(), - claims.providerLogin(), - trustedEmail(claims), - (String) claims.extra().get("avatar_url") - ); - user.setStatus(UserStatus.PENDING); - user = userRepo.save(user); - - IdentityBinding binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin()); - bindingRepo.save(binding); - } - - private String trustedEmail(OAuthClaims claims) { - return claims.emailVerified() ? claims.email() : null; - } - - private void ensureExternalLoginAllowed(UserAccount user) { - if (user.isSystemAccount()) { - throw new SystemAccountLoginException(); - } - if (user.getStatus() == UserStatus.PENDING) { - throw new AccountPendingException(); - } - if (user.getStatus() == UserStatus.DISABLED) { - throw new AccountDisabledException(); - } - if (user.getStatus() == UserStatus.MERGED) { - throw new AccountMergedException(); - } - } -} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityCoreException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityCoreException.java new file mode 100644 index 00000000..b5d32dd2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityCoreException.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +/** + * Security-oriented identity failure carrying only a stable reason code. + */ +public class IdentityCoreException extends RuntimeException { + private final IdentityFailureCode reasonCode; + + public IdentityCoreException(IdentityFailureCode reasonCode) { + super(Objects.requireNonNull(reasonCode, "reasonCode").name()); + this.reasonCode = reasonCode; + } + + public IdentityCoreException(IdentityFailureCode reasonCode, Throwable cause) { + super(Objects.requireNonNull(reasonCode, "reasonCode").name(), cause); + this.reasonCode = reasonCode; + } + + public IdentityFailureCode getReasonCode() { + return reasonCode; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityFailureCode.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityFailureCode.java new file mode 100644 index 00000000..b1cd27c4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityFailureCode.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.auth.identity; + +/** + * Stable failure categories emitted by the unified identity core. + */ +public enum IdentityFailureCode { + PROVIDER_DISABLED, + PROVIDER_AUTHORITY_MISMATCH, + INVALID_IDENTITY_ASSERTION, + IDENTITY_SUBJECT_MISSING, + IDENTITY_IDENTIFIER_CONFLICT, + ACCESS_DENIED, + ACCOUNT_PENDING, + ACCOUNT_DISABLED, + ACCOUNT_MERGED, + SYSTEM_ACCOUNT_FORBIDDEN +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLoginContext.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLoginContext.java new file mode 100644 index 00000000..12fa7893 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLoginContext.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.auth.identity; + +/** + * Non-sensitive request metadata available to the identity core for audit and + * metrics. It deliberately does not expose servlet or session objects. + */ +public record IdentityLoginContext( + String requestId, + String clientIp, + String userAgent +) { + public IdentityLoginContext { + validateLength(requestId, 64, "requestId"); + validateLength(clientIp, 64, "clientIp"); + validateLength(userAgent, 512, "userAgent"); + } + + public static IdentityLoginContext empty() { + return new IdentityLoginContext(null, null, null); + } + + private static void validateLength( + String value, + int maximum, + String field) { + if (value != null && value.length() > maximum) { + throw new IllegalArgumentException(field + " is too long"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLoginOutcome.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLoginOutcome.java new file mode 100644 index 00000000..209c13ff --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLoginOutcome.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import java.util.Objects; + +/** + * Business outcomes of the external identity transaction. + */ +public sealed interface IdentityLoginOutcome { + + record Authenticated( + PlatformPrincipal principal, + boolean accountCreated, + boolean bindingCreated + ) implements IdentityLoginOutcome { + public Authenticated { + Objects.requireNonNull(principal, "principal"); + } + } + + record PendingApproval( + String reasonCode + ) implements IdentityLoginOutcome { + public PendingApproval { + requireReasonCode(reasonCode); + } + } + + record LinkRequired( + String reasonCode + ) implements IdentityLoginOutcome { + public LinkRequired { + requireReasonCode(reasonCode); + } + } + + private static void requireReasonCode(String reasonCode) { + Objects.requireNonNull(reasonCode, "reasonCode"); + if (reasonCode.isBlank() || reasonCode.length() > 64) { + throw new IllegalArgumentException("Invalid identity reason code"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderAuthorityOperations.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderAuthorityOperations.java new file mode 100644 index 00000000..eec58f19 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderAuthorityOperations.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.auth.identity; + +/** + * Administrative operations for a persisted provider authority lock. + */ +public interface IdentityProviderAuthorityOperations { + + IdentityProviderAuthorityRecoveryResult recoverSameAuthority( + String providerCode, + IdentityProviderAuthorityRecoveryContext context); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderAuthorityRecoveryContext.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderAuthorityRecoveryContext.java new file mode 100644 index 00000000..00e1d74d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderAuthorityRecoveryContext.java @@ -0,0 +1,33 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +/** + * Authenticated operator and bounded request metadata for an authority + * recovery audit. + */ +public record IdentityProviderAuthorityRecoveryContext( + String actorUserId, + String requestId, + String clientIp, + String userAgent +) { + public IdentityProviderAuthorityRecoveryContext { + Objects.requireNonNull(actorUserId, "actorUserId"); + if (actorUserId.isBlank() || actorUserId.length() > 128) { + throw new IllegalArgumentException("Invalid actor user id"); + } + validateLength(requestId, 64, "requestId"); + validateLength(clientIp, 64, "clientIp"); + validateLength(userAgent, 512, "userAgent"); + } + + private static void validateLength( + String value, + int maximum, + String field) { + if (value != null && value.length() > maximum) { + throw new IllegalArgumentException(field + " is too long"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderAuthorityRecoveryResult.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderAuthorityRecoveryResult.java new file mode 100644 index 00000000..19646679 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderAuthorityRecoveryResult.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +/** + * Observable result of an idempotent same-authority recovery request. + */ +public record IdentityProviderAuthorityRecoveryResult( + String providerCode, + boolean recovered, + String state +) { + public IdentityProviderAuthorityRecoveryResult { + Objects.requireNonNull(providerCode, "providerCode"); + Objects.requireNonNull(state, "state"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderCatalog.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderCatalog.java new file mode 100644 index 00000000..ebc97e25 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderCatalog.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.List; + +/** + * Read-only projection of external identity providers that are safe to expose + * as interactive login methods. + */ +public interface IdentityProviderCatalog { + + List listReadyProviders(); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethod.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethod.java new file mode 100644 index 00000000..ae6b435e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderLoginMethod.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +/** + * Presentation-safe provider metadata. Authority and protocol details remain + * internal to the identity core. + */ +public record IdentityProviderLoginMethod( + String providerCode, + String displayName +) { + public IdentityProviderLoginMethod { + Objects.requireNonNull(providerCode, "providerCode"); + Objects.requireNonNull(displayName, "displayName"); + if (providerCode.isBlank() || displayName.isBlank()) { + throw new IllegalArgumentException( + "Provider code and display name are required"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderReadinessService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderReadinessService.java new file mode 100644 index 00000000..970c29e3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderReadinessService.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.auth.identity; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; + +/** + * Fail-closed gate for a server-owned interactive identity provider route. + */ +public interface IdentityProviderReadinessService { + + void requireReady(ClientRegistration registration); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderState.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderState.java new file mode 100644 index 00000000..708bc77f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderState.java @@ -0,0 +1,131 @@ +package com.iflytek.skillhub.auth.identity; + +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 java.time.Instant; + +@Entity +@Table(name = "identity_provider_state") +class IdentityProviderState { + + @Id + @Column(name = "provider_code", nullable = false, length = 64) + private String providerCode; + + @Column(nullable = false, length = 32) + private String protocol; + + @Column(length = 512) + private String authority; + + @Column(name = "authority_fingerprint", length = 64) + private String authorityFingerprint; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private IdentityProviderStatus state; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "last_seen_at") + private Instant lastSeenAt; + + protected IdentityProviderState() { + } + + private IdentityProviderState( + String providerCode, + String protocol, + String authority, + String authorityFingerprint, + IdentityProviderStatus state, + Instant createdAt, + Instant lastSeenAt) { + this.providerCode = providerCode; + this.protocol = protocol; + this.authority = authority; + this.authorityFingerprint = authorityFingerprint; + this.state = state; + this.createdAt = createdAt; + this.lastSeenAt = lastSeenAt; + } + + static IdentityProviderState ready( + String providerCode, + String protocol, + String authority, + String authorityFingerprint, + Instant observedAt) { + return new IdentityProviderState( + providerCode, + protocol, + authority, + authorityFingerprint, + IdentityProviderStatus.READY, + observedAt, + observedAt); + } + + static IdentityProviderState legacyUnpinned( + String providerCode, + String protocol, + Instant createdAt) { + return new IdentityProviderState( + providerCode, + protocol, + null, + null, + IdentityProviderStatus.LEGACY_UNPINNED, + createdAt, + null); + } + + static IdentityProviderState authorityMismatch( + String providerCode, + String protocol, + String authority, + String authorityFingerprint, + Instant observedAt) { + return new IdentityProviderState( + providerCode, + protocol, + authority, + authorityFingerprint, + IdentityProviderStatus.AUTHORITY_MISMATCH, + observedAt, + observedAt); + } + + String getProviderCode() { + return providerCode; + } + + String getProtocol() { + return protocol; + } + + String getAuthority() { + return authority; + } + + String getAuthorityFingerprint() { + return authorityFingerprint; + } + + IdentityProviderStatus getState() { + return state; + } + + Instant getCreatedAt() { + return createdAt; + } + + Instant getLastSeenAt() { + return lastSeenAt; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderStateRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderStateRepository.java new file mode 100644 index 00000000..caa7b59c --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderStateRepository.java @@ -0,0 +1,153 @@ +package com.iflytek.skillhub.auth.identity; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * PostgreSQL compare-and-set operations for the provider authority lock. + * + *

Native SQL is required here because authority pinning must use + * {@code INSERT ... ON CONFLICT DO NOTHING} and state-guarded updates in the + * database. A read-then-save JPA sequence would allow concurrent application + * instances to overwrite the authority chosen by another instance. + */ +@Repository +interface IdentityProviderStateRepository + extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + INSERT INTO identity_provider_state ( + provider_code, + protocol, + authority, + authority_fingerprint, + state, + created_at, + last_seen_at + ) VALUES ( + :providerCode, + :protocol, + :authority, + :fingerprint, + 'READY', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON CONFLICT (provider_code) DO NOTHING + """, nativeQuery = true) + int insertReady( + @Param("providerCode") String providerCode, + @Param("protocol") String protocol, + @Param("authority") String authority, + @Param("fingerprint") String fingerprint); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + INSERT INTO identity_provider_state ( + provider_code, + protocol, + authority, + authority_fingerprint, + state, + created_at, + last_seen_at + ) VALUES ( + :providerCode, + :protocol, + NULL, + NULL, + 'LEGACY_UNPINNED', + CURRENT_TIMESTAMP, + NULL + ) + ON CONFLICT (provider_code) DO NOTHING + """, nativeQuery = true) + int insertLegacyUnpinned( + @Param("providerCode") String providerCode, + @Param("protocol") String protocol); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE identity_provider_state + SET authority = :authority, + authority_fingerprint = :fingerprint, + state = 'READY', + last_seen_at = CURRENT_TIMESTAMP + WHERE provider_code = :providerCode + AND protocol = :protocol + AND state = 'LEGACY_UNPINNED' + AND authority IS NULL + AND authority_fingerprint IS NULL + """, nativeQuery = true) + int pinLegacy( + @Param("providerCode") String providerCode, + @Param("protocol") String protocol, + @Param("authority") String authority, + @Param("fingerprint") String fingerprint); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE identity_provider_state + SET state = 'MISCONFIGURED', + last_seen_at = CURRENT_TIMESTAMP + WHERE provider_code = :providerCode + AND state = 'LEGACY_UNPINNED' + AND authority IS NULL + AND authority_fingerprint IS NULL + AND protocol <> :protocol + """, nativeQuery = true) + int markLegacyProtocolMismatch( + @Param("providerCode") String providerCode, + @Param("protocol") String protocol); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE identity_provider_state + SET state = 'AUTHORITY_MISMATCH', + last_seen_at = CURRENT_TIMESTAMP + WHERE provider_code = :providerCode + AND state IN ('READY', 'DEGRADED') + AND authority_fingerprint IS NOT NULL + AND ( + protocol <> :protocol + OR authority_fingerprint <> :fingerprint + ) + """, nativeQuery = true) + int markAuthorityMismatch( + @Param("providerCode") String providerCode, + @Param("protocol") String protocol, + @Param("fingerprint") String fingerprint); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE identity_provider_state + SET state = 'READY', + last_seen_at = CURRENT_TIMESTAMP + WHERE provider_code = :providerCode + AND state = 'AUTHORITY_MISMATCH' + AND protocol = :protocol + AND authority_fingerprint = :fingerprint + """, nativeQuery = true) + int recoverSameAuthority( + @Param("providerCode") String providerCode, + @Param("protocol") String protocol, + @Param("fingerprint") String fingerprint); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE identity_provider_state + SET last_seen_at = CURRENT_TIMESTAMP + WHERE provider_code = :providerCode + AND state = 'READY' + AND protocol = :protocol + AND authority_fingerprint = :fingerprint + """, nativeQuery = true) + int touchReady( + @Param("providerCode") String providerCode, + @Param("protocol") String protocol, + @Param("fingerprint") String fingerprint); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderStatus.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderStatus.java new file mode 100644 index 00000000..f7e753eb --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderStatus.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.identity; + +enum IdentityProviderStatus { + READY, + DISABLED, + MISCONFIGURED, + DEGRADED, + AUTHORITY_MISMATCH, + LEGACY_UNPINNED +} 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 new file mode 100644 index 00000000..1c06079b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Short database transaction that preserves the existing identity-binding and + * provisioning behavior behind the unified core facade. + */ +@Service +class IdentityResolutionTransaction { + + private static final String ACCOUNT_PENDING = "ACCOUNT_PENDING"; + + private final IdentityBindingRepository bindingRepository; + private final UserAccountRepository userRepository; + private final GlobalNamespaceMembershipService membershipService; + private final AccountLoginGuard accountLoginGuard; + private final PlatformPrincipalFactory principalFactory; + + IdentityResolutionTransaction( + IdentityBindingRepository bindingRepository, + UserAccountRepository userRepository, + GlobalNamespaceMembershipService membershipService, + AccountLoginGuard accountLoginGuard, + PlatformPrincipalFactory principalFactory) { + this.bindingRepository = bindingRepository; + this.userRepository = userRepository; + this.membershipService = membershipService; + this.accountLoginGuard = accountLoginGuard; + this.principalFactory = principalFactory; + } + + @Transactional + public IdentityLoginOutcome resolve( + IdentityAssertion assertion, + UserStatus initialStatus) { + IdentityBinding binding = bindingRepository + .findByProviderCodeAndSubject( + assertion.provider().providerCode(), + assertion.primarySubject().value()) + .orElse(null); + if (binding != null) { + return resolveExisting(assertion, binding); + } + return createAccount(assertion, initialStatus); + } + + private IdentityLoginOutcome resolveExisting( + IdentityAssertion assertion, + IdentityBinding binding) { + UserAccount user = userRepository.findById(binding.getUserId()) + .orElseThrow(() -> new IllegalStateException( + "User not found for identity binding")); + AccountLoginDecision decision = + accountLoginGuard.evaluateInteractive(user); + if (decision == AccountLoginDecision.PENDING) { + return new IdentityLoginOutcome.PendingApproval(ACCOUNT_PENDING); + } + requireAllowed(decision); + + synchronizeCompatibilityProfile(user, assertion.profile()); + user = userRepository.save(user); + return new IdentityLoginOutcome.Authenticated( + principalFactory.create( + user, + assertion.provider().providerCode()), + false, + false); + } + + private IdentityLoginOutcome createAccount( + IdentityAssertion assertion, + UserStatus initialStatus) { + ExternalProfile profile = assertion.profile(); + UserAccount user = new UserAccount( + "usr_" + UUID.randomUUID(), + profile.displayName(), + trustedEmail(profile).orElse(null), + profile.avatarUrl().map(Object::toString).orElse(null)); + user.setStatus(initialStatus); + user = userRepository.save(user); + + if (initialStatus == UserStatus.ACTIVE) { + membershipService.ensureMember(user.getId()); + } + bindingRepository.save(new IdentityBinding( + user.getId(), + assertion.provider().providerCode(), + assertion.primarySubject().value(), + profile.displayName())); + + if (initialStatus == UserStatus.PENDING) { + return new IdentityLoginOutcome.PendingApproval(ACCOUNT_PENDING); + } + requireAllowed(accountLoginGuard.evaluateInteractive(user)); + return new IdentityLoginOutcome.Authenticated( + principalFactory.create( + user, + assertion.provider().providerCode()), + true, + true); + } + + private void synchronizeCompatibilityProfile( + UserAccount user, + ExternalProfile profile) { + user.setDisplayName(profile.displayName()); + trustedEmail(profile).ifPresent(user::setEmail); + profile.avatarUrl() + .map(Object::toString) + .ifPresent(user::setAvatarUrl); + } + + private Optional trustedEmail(ExternalProfile profile) { + return profile.email() + .filter(claim -> claim.assurance().isVerifiedOrAuthoritative()) + .map(EmailClaim::value); + } + + private void requireAllowed(AccountLoginDecision decision) { + IdentityFailureCode failureCode = switch (decision) { + case ALLOWED -> null; + case PENDING -> IdentityFailureCode.ACCOUNT_PENDING; + case DISABLED -> IdentityFailureCode.ACCOUNT_DISABLED; + case MERGED -> IdentityFailureCode.ACCOUNT_MERGED; + case SYSTEM_ACCOUNT -> + IdentityFailureCode.SYSTEM_ACCOUNT_FORBIDDEN; + }; + if (failureCode != null) { + throw new IdentityCoreException(failureCode); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/PlatformPrincipalFactory.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/PlatformPrincipalFactory.java new file mode 100644 index 00000000..b0b58d10 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/PlatformPrincipalFactory.java @@ -0,0 +1,50 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; + +/** + * Builds the shared serializable principal snapshot from platform-owned user + * and role data. + */ +@Service +public class PlatformPrincipalFactory { + + private final UserRoleBindingRepository roleBindingRepository; + + public PlatformPrincipalFactory( + UserRoleBindingRepository roleBindingRepository) { + this.roleBindingRepository = roleBindingRepository; + } + + public PlatformPrincipal create( + UserAccount user, + String authenticationProvider) { + Objects.requireNonNull(user, "user"); + Objects.requireNonNull(authenticationProvider, "authenticationProvider"); + if (authenticationProvider.isBlank()) { + throw new IllegalArgumentException( + "Authentication provider must not be blank"); + } + + Set roles = roleBindingRepository.findByUserId(user.getId()) + .stream() + .map(binding -> binding.getRole().getCode()) + .collect(Collectors.toSet()); + roles = PlatformRoleDefaults.withDefaultUserRole(roles); + + return new PlatformPrincipal( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getAvatarUrl(), + authenticationProvider, + roles); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProtocolAuthenticationEvidence.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProtocolAuthenticationEvidence.java new file mode 100644 index 00000000..a0183665 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProtocolAuthenticationEvidence.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.auth.identity; + +import java.time.Instant; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Non-secret authentication facts reported by a protocol adapter. + */ +public record ProtocolAuthenticationEvidence( + String protocol, + Instant authenticatedAt, + Set authenticationMethods +) { + private static final Pattern CODE_PATTERN = + Pattern.compile("[a-z][a-z0-9_-]{0,63}"); + + public ProtocolAuthenticationEvidence { + Objects.requireNonNull(protocol, "protocol"); + Objects.requireNonNull(authenticatedAt, "authenticatedAt"); + Objects.requireNonNull(authenticationMethods, "authenticationMethods"); + if (!CODE_PATTERN.matcher(protocol).matches()) { + throw new IllegalArgumentException("Invalid protocol code"); + } + authenticationMethods = Set.copyOf(authenticationMethods); + if (authenticationMethods.size() > 16) { + throw new IllegalArgumentException("Too many authentication methods"); + } + for (String method : authenticationMethods) { + if (method == null || !CODE_PATTERN.matcher(method).matches()) { + throw new IllegalArgumentException("Invalid authentication method"); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAssertionLimits.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAssertionLimits.java new file mode 100644 index 00000000..21b19f96 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAssertionLimits.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.auth.identity; + +final class ProviderAssertionLimits { + static final int MAX_SUBJECT_VALUE_LENGTH = 256; + static final int MAX_ATTRIBUTE_COUNT = 64; + static final int MAX_VALUES_PER_ATTRIBUTE = 16; + static final int MAX_ATTRIBUTE_VALUE_LENGTH = 2_048; + static final int MAX_TOTAL_PAYLOAD_LENGTH = 32_768; + + private ProviderAssertionLimits() { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAttributeTrust.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAttributeTrust.java new file mode 100644 index 00000000..cfa4e7d7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAttributeTrust.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.identity; + +/** + * Trust level asserted by a protocol adapter for one upstream attribute. + * + *

The identity core always clamps this value to the configured provider + * descriptor and never treats it as an authorization decision.

+ */ +public enum ProviderAttributeTrust { + UNVERIFIED, + ASSERTED, + VERIFIED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAttributeValue.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAttributeValue.java new file mode 100644 index 00000000..2a8df848 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAttributeValue.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +/** + * One non-secret attribute value extracted from an already verified provider + * response. + */ +public record ProviderAttributeValue( + String value, + ProviderAttributeTrust trust +) { + private static final int ADAPTER_VALUE_LIMIT = 8_192; + + public ProviderAttributeValue { + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(trust, "trust"); + if (value.length() > ADAPTER_VALUE_LIMIT) { + throw new IllegalArgumentException("Provider attribute value is too long"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthenticationResult.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthenticationResult.java new file mode 100644 index 00000000..4b537bf2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthenticationResult.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Protocol-neutral output of a trusted authentication adapter. + * + *

This record intentionally has no provider code, authority, platform user + * id, role, principal, session, token, ticket, cookie, or raw response field. + * The identity core binds these facts to a trusted server-side descriptor.

+ */ +public record ProviderAuthenticationResult( + SubjectCandidate primarySubject, + List alternateSubjects, + Map> attributes, + ProtocolAuthenticationEvidence evidence +) { + private static final Pattern ATTRIBUTE_KEY_PATTERN = + Pattern.compile("[A-Za-z][A-Za-z0-9_.:-]{0,127}"); + + public ProviderAuthenticationResult { + Objects.requireNonNull(primarySubject, "primarySubject"); + Objects.requireNonNull(alternateSubjects, "alternateSubjects"); + Objects.requireNonNull(attributes, "attributes"); + Objects.requireNonNull(evidence, "evidence"); + + alternateSubjects = List.copyOf(alternateSubjects); + LinkedHashMap> copied = new LinkedHashMap<>(); + attributes.forEach((key, values) -> { + if (key == null || !ATTRIBUTE_KEY_PATTERN.matcher(key).matches()) { + throw new IllegalArgumentException("Invalid provider attribute key"); + } + Objects.requireNonNull(values, "Provider attribute values"); + List copiedValues = List.copyOf(values); + if (copiedValues.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("Provider attribute values must not contain null"); + } + copied.put(key, copiedValues); + }); + attributes = Map.copyOf(copied); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityFingerprint.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityFingerprint.java new file mode 100644 index 00000000..bfcb5678 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityFingerprint.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.auth.identity; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +final class ProviderAuthorityFingerprint { + + private ProviderAuthorityFingerprint() { + } + + static String sha256(String protocol, String canonicalAuthority) { + Objects.requireNonNull(protocol, "protocol"); + Objects.requireNonNull(canonicalAuthority, "canonicalAuthority"); + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] input = (protocol + "\n" + canonicalAuthority) + .getBytes(StandardCharsets.UTF_8); + return HexFormat.of().formatHex(digest.digest(input)); + } 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/ProviderAuthorityLockService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityLockService.java new file mode 100644 index 00000000..63a1eb31 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityLockService.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.auth.identity; + +import org.springframework.stereotype.Service; + +@Service +class ProviderAuthorityLockService { + + private final ProviderAuthorityStateTransaction stateTransaction; + + ProviderAuthorityLockService( + ProviderAuthorityStateTransaction stateTransaction) { + this.stateTransaction = stateTransaction; + } + + void requirePinnedAuthority(ProviderDescriptor descriptor) { + String fingerprint = fingerprint(descriptor); + AuthorityLockEvaluation evaluation = + stateTransaction.pin(descriptor, fingerprint); + if (evaluation.ready()) { + return; + } + if (evaluation.state() + == IdentityProviderStatus.AUTHORITY_MISMATCH) { + throw new IdentityCoreException( + IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH); + } + throw new IdentityCoreException( + IdentityFailureCode.PROVIDER_DISABLED); + } + + boolean isReady(ProviderDescriptor descriptor) { + AuthorityLockEvaluation evaluation = + stateTransaction.read(descriptor.providerCode()); + return evaluation.ready() + && fingerprint(descriptor) + .equals(evaluation.persistedFingerprint()); + } + + SameAuthorityRecoveryEvaluation recoverSameAuthority( + ProviderDescriptor descriptor, + IdentityProviderAuthorityRecoveryContext context) { + return stateTransaction.recoverSameAuthority( + descriptor, + fingerprint(descriptor), + context); + } + + private String fingerprint(ProviderDescriptor descriptor) { + return ProviderAuthorityFingerprint.sha256( + descriptor.protocol(), + descriptor.canonicalAuthority()); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityStateTransaction.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityStateTransaction.java new file mode 100644 index 00000000..75e08b17 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityStateTransaction.java @@ -0,0 +1,182 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import java.util.Optional; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +class ProviderAuthorityStateTransaction { + + private final IdentityProviderStateRepository stateRepository; + private final IdentityBindingRepository bindingRepository; + private final AuditLogService auditLogService; + + ProviderAuthorityStateTransaction( + IdentityProviderStateRepository stateRepository, + IdentityBindingRepository bindingRepository, + AuditLogService auditLogService) { + this.stateRepository = stateRepository; + this.bindingRepository = bindingRepository; + this.auditLogService = auditLogService; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public AuthorityLockEvaluation pin( + ProviderDescriptor descriptor, + String expectedFingerprint) { + Optional existing = + stateRepository.findById(descriptor.providerCode()); + if (existing.isEmpty()) { + createInitialState(descriptor, expectedFingerprint); + existing = stateRepository.findById(descriptor.providerCode()); + } + + IdentityProviderState state = existing.orElseThrow( + () -> new IllegalStateException( + "Provider authority state was not persisted")); + if (state.getState() == IdentityProviderStatus.LEGACY_UNPINNED) { + if (!descriptor.protocol().equals(state.getProtocol())) { + stateRepository.markLegacyProtocolMismatch( + descriptor.providerCode(), + descriptor.protocol()); + } else { + stateRepository.pinLegacy( + descriptor.providerCode(), + descriptor.protocol(), + descriptor.canonicalAuthority(), + expectedFingerprint); + } + state = reread(descriptor.providerCode()); + } + + if (state.getState() == IdentityProviderStatus.AUTHORITY_MISMATCH) { + return evaluation(state); + } + + if (state.getState() == IdentityProviderStatus.READY + && descriptor.protocol().equals(state.getProtocol()) + && expectedFingerprint.equals(state.getAuthorityFingerprint())) { + int touched = stateRepository.touchReady( + descriptor.providerCode(), + descriptor.protocol(), + expectedFingerprint); + if (touched == 0) { + state = reread(descriptor.providerCode()); + } + return evaluation(state); + } + + if ((state.getState() == IdentityProviderStatus.READY + || state.getState() == IdentityProviderStatus.DEGRADED) + && (!descriptor.protocol().equals(state.getProtocol()) + || !expectedFingerprint.equals(state.getAuthorityFingerprint()))) { + stateRepository.markAuthorityMismatch( + descriptor.providerCode(), + descriptor.protocol(), + expectedFingerprint); + state = reread(descriptor.providerCode()); + } + return evaluation(state); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) + public AuthorityLockEvaluation read(String providerCode) { + return stateRepository.findById(providerCode) + .map(this::evaluation) + .orElse(new AuthorityLockEvaluation( + IdentityProviderStatus.MISCONFIGURED, + null)); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public SameAuthorityRecoveryEvaluation recoverSameAuthority( + ProviderDescriptor descriptor, + String expectedFingerprint, + IdentityProviderAuthorityRecoveryContext context) { + int updated = stateRepository.recoverSameAuthority( + descriptor.providerCode(), + descriptor.protocol(), + expectedFingerprint); + AuthorityLockEvaluation authority = stateRepository + .findById(descriptor.providerCode()) + .map(this::evaluation) + .orElse(new AuthorityLockEvaluation( + IdentityProviderStatus.MISCONFIGURED, + null)); + if (authority.ready() + && !expectedFingerprint.equals( + authority.persistedFingerprint())) { + stateRepository.markAuthorityMismatch( + descriptor.providerCode(), + descriptor.protocol(), + expectedFingerprint); + authority = stateRepository + .findById(descriptor.providerCode()) + .map(this::evaluation) + .orElse(new AuthorityLockEvaluation( + IdentityProviderStatus.MISCONFIGURED, + null)); + } + boolean recovered = updated == 1 + && authority.ready() + && expectedFingerprint.equals( + authority.persistedFingerprint()); + if (recovered) { + auditLogService.record( + context.actorUserId(), + "PROVIDER_AUTHORITY_RECOVERED", + "IDENTITY_PROVIDER", + null, + context.requestId(), + context.clientIp(), + context.userAgent(), + recoveryDetail(descriptor, expectedFingerprint)); + } + return new SameAuthorityRecoveryEvaluation( + recovered, + authority); + } + + private void createInitialState( + ProviderDescriptor descriptor, + String expectedFingerprint) { + if (bindingRepository.existsByProviderCode(descriptor.providerCode())) { + stateRepository.insertLegacyUnpinned( + descriptor.providerCode(), + descriptor.protocol()); + return; + } + stateRepository.insertReady( + descriptor.providerCode(), + descriptor.protocol(), + descriptor.canonicalAuthority(), + expectedFingerprint); + } + + private IdentityProviderState reread(String providerCode) { + return stateRepository.findById(providerCode).orElseThrow( + () -> new IllegalStateException( + "Provider authority state disappeared")); + } + + private AuthorityLockEvaluation evaluation(IdentityProviderState state) { + return new AuthorityLockEvaluation( + state.getState(), + state.getAuthorityFingerprint()); + } + + private String recoveryDetail( + ProviderDescriptor descriptor, + String fingerprint) { + return "{\"providerCode\":\"" + + descriptor.providerCode() + + "\",\"protocol\":\"" + + descriptor.protocol() + + "\",\"authorityFingerprint\":\"" + + fingerprint + + "\"}"; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderDescriptor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderDescriptor.java new file mode 100644 index 00000000..3d960446 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderDescriptor.java @@ -0,0 +1,59 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +record ProviderDescriptor( + String providerCode, + String protocol, + String canonicalAuthority, + String displayName, + String primarySubjectType, + Set allowedSubjectTypes, + SubjectCanonicalizer subjectCanonicalizer, + List displayNameAttributes, + List emailAttributes, + List avatarAttributes, + EmailAssurance emailAssuranceLimit +) { + private static final Pattern PROVIDER_CODE_PATTERN = + Pattern.compile("[a-z0-9][a-z0-9._-]{0,63}"); + private static final Pattern PROTOCOL_PATTERN = + Pattern.compile("[a-z][a-z0-9_-]{0,31}"); + + ProviderDescriptor { + Objects.requireNonNull(providerCode, "providerCode"); + Objects.requireNonNull(protocol, "protocol"); + Objects.requireNonNull(canonicalAuthority, "canonicalAuthority"); + Objects.requireNonNull(displayName, "displayName"); + Objects.requireNonNull(primarySubjectType, "primarySubjectType"); + Objects.requireNonNull(allowedSubjectTypes, "allowedSubjectTypes"); + Objects.requireNonNull(subjectCanonicalizer, "subjectCanonicalizer"); + Objects.requireNonNull(displayNameAttributes, "displayNameAttributes"); + Objects.requireNonNull(emailAttributes, "emailAttributes"); + Objects.requireNonNull(avatarAttributes, "avatarAttributes"); + Objects.requireNonNull(emailAssuranceLimit, "emailAssuranceLimit"); + + if (!PROVIDER_CODE_PATTERN.matcher(providerCode).matches()) { + throw new IllegalArgumentException("Invalid provider code"); + } + if (!PROTOCOL_PATTERN.matcher(protocol).matches()) { + throw new IllegalArgumentException("Invalid protocol code"); + } + if (canonicalAuthority.isBlank() || canonicalAuthority.length() > 512) { + throw new IllegalArgumentException("Invalid provider authority"); + } + if (displayName.isBlank() || displayName.length() > 128) { + throw new IllegalArgumentException("Invalid provider display name"); + } + allowedSubjectTypes = Set.copyOf(allowedSubjectTypes); + if (!allowedSubjectTypes.contains(primarySubjectType)) { + throw new IllegalArgumentException("Primary subject type is not allowed"); + } + displayNameAttributes = List.copyOf(displayNameAttributes); + emailAttributes = List.copyOf(emailAttributes); + avatarAttributes = List.copyOf(avatarAttributes); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderReference.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderReference.java new file mode 100644 index 00000000..bbb7ed8f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderReference.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +record ProviderReference( + String providerCode, + String protocol, + String authority +) { + ProviderReference { + Objects.requireNonNull(providerCode, "providerCode"); + Objects.requireNonNull(protocol, "protocol"); + Objects.requireNonNull(authority, "authority"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java new file mode 100644 index 00000000..eb51a822 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalog.java @@ -0,0 +1,95 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** + * Startup projection for the transitional static provider registry. + * + *

Startup reconciliation performs the authority compare-and-set. Every + * catalog read then filters the configured descriptor snapshot against the + * current persisted state, so mismatch and recovery changes made by another + * application instance are visible without a process restart. + */ +@Component +class ReconciledIdentityProviderCatalog + implements IdentityProviderCatalog, ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger( + ReconciledIdentityProviderCatalog.class); + + private final TrustedProviderDescriptorSource descriptorSource; + private final ProviderAuthorityLockService authorityLockService; + private final AtomicReference> + configuredProviders = new AtomicReference<>(List.of()); + + ReconciledIdentityProviderCatalog( + TrustedProviderDescriptorSource descriptorSource, + ProviderAuthorityLockService authorityLockService) { + this.descriptorSource = descriptorSource; + this.authorityLockService = authorityLockService; + } + + @Override + public void run(ApplicationArguments args) { + reconcile(); + } + + synchronized void reconcile() { + List descriptors; + try { + descriptors = List.copyOf( + descriptorSource.enabledDescriptors()); + } catch (RuntimeException exception) { + configuredProviders.set(List.of()); + log.error( + "Identity provider descriptor reconciliation failed", + exception); + return; + } + configuredProviders.set(descriptors); + for (ProviderDescriptor descriptor : descriptors) { + try { + authorityLockService.requirePinnedAuthority(descriptor); + } catch (IdentityCoreException exception) { + log.warn( + "Identity provider '{}' is hidden after authority reconciliation: {}", + descriptor.providerCode(), + exception.getReasonCode()); + } catch (RuntimeException exception) { + log.error( + "Identity provider '{}' is hidden because authority reconciliation failed", + descriptor.providerCode(), + exception); + } + } + } + + @Override + public List listReadyProviders() { + return configuredProviders.get().stream() + .filter(this::isCurrentlyReady) + .map(descriptor -> new IdentityProviderLoginMethod( + descriptor.providerCode(), + descriptor.displayName())) + .toList(); + } + + private boolean isCurrentlyReady(ProviderDescriptor descriptor) { + try { + authorityLockService.requirePinnedAuthority(descriptor); + return authorityLockService.isReady(descriptor); + } catch (RuntimeException exception) { + log.error( + "Identity provider '{}' is hidden because its persisted state cannot be read", + descriptor.providerCode(), + exception); + return false; + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ResolvedProviderHandle.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ResolvedProviderHandle.java new file mode 100644 index 00000000..4ac9414e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ResolvedProviderHandle.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.identity; + +/** + * Opaque handle issued only after a server-owned provider route is resolved. + */ +public sealed interface ResolvedProviderHandle + permits DefaultResolvedProviderHandle { + + String providerCode(); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/SameAuthorityRecoveryEvaluation.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/SameAuthorityRecoveryEvaluation.java new file mode 100644 index 00000000..b68da558 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/SameAuthorityRecoveryEvaluation.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.auth.identity; + +record SameAuthorityRecoveryEvaluation( + boolean recovered, + AuthorityLockEvaluation authority +) { +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java new file mode 100644 index 00000000..1a27a0ac --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java @@ -0,0 +1,366 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.oauth.OAuthClaimsExtractor; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.stereotype.Component; + +/** + * Transitional trusted descriptor source for the existing GitHub, GitLab and + * OIDC Spring Security registrations. + */ +@Component +class StaticTrustedProviderDescriptorSource + implements TrustedProviderDescriptorSource, + TrustedProviderRouteResolver { + + private static final Logger log = LoggerFactory.getLogger( + StaticTrustedProviderDescriptorSource.class); + + private final Map descriptors; + private final Map trustedRegistrations; + + @Autowired + StaticTrustedProviderDescriptorSource( + OAuth2ClientProperties properties, + ClientRegistrationRepository registrationRepository, + List extractors) { + this( + properties, + registrationRepository, + extractors.stream() + .map(OAuthClaimsExtractor::getProvider) + .collect(Collectors.toUnmodifiableSet())); + } + + StaticTrustedProviderDescriptorSource( + OAuth2ClientProperties properties, + ClientRegistrationRepository registrationRepository, + Set extractorCodes) { + Map resolvedDescriptors = + new LinkedHashMap<>(); + Map resolvedRegistrations = + new LinkedHashMap<>(); + + properties.getRegistration().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> resolveConfiguredRegistration( + entry.getKey(), + entry.getValue(), + registrationRepository, + extractorCodes).ifPresent(resolved -> { + resolvedDescriptors.put( + entry.getKey(), + resolved.descriptor()); + resolvedRegistrations.put( + entry.getKey(), + resolved.registration()); + })); + descriptors = Map.copyOf(resolvedDescriptors); + trustedRegistrations = Map.copyOf(resolvedRegistrations); + } + + @Override + public ResolvedProviderHandle resolve( + ClientRegistration registration) { + if (registration == null) { + throw providerDisabled(); + } + String providerCode = registration.getRegistrationId(); + ClientRegistration trusted = trustedRegistrations.get(providerCode); + // Spring Boot's static OAuth client configuration uses the canonical + // ClientRegistration instances held by its in-memory repository. + // Matching selected fields would let a reconstructed object omit or + // replace other security-relevant registration fields. + if (trusted == null || trusted != registration) { + throw providerDisabled(); + } + return new DefaultResolvedProviderHandle(providerCode); + } + + @Override + public ProviderDescriptor require(ResolvedProviderHandle provider) { + if (!(provider instanceof DefaultResolvedProviderHandle handle)) { + throw providerDisabled(); + } + ProviderDescriptor descriptor = + descriptors.get(handle.providerCode()); + if (descriptor == null) { + throw providerDisabled(); + } + return descriptor; + } + + @Override + public List enabledDescriptors() { + return descriptors.values().stream() + .sorted(Comparator.comparing( + ProviderDescriptor::providerCode)) + .toList(); + } + + private Optional resolveConfiguredRegistration( + String providerCode, + OAuth2ClientProperties.Registration properties, + ClientRegistrationRepository registrationRepository, + Set extractorCodes) { + if (!hasRealClientId(properties.getClientId())) { + return Optional.empty(); + } + ClientRegistration registration; + try { + registration = registrationRepository + .findByRegistrationId(providerCode); + } catch (RuntimeException exception) { + log.warn( + "Identity provider '{}' is hidden because its client registration cannot be resolved", + providerCode); + return Optional.empty(); + } + if (registration == null) { + return Optional.empty(); + } + + try { + ProviderDescriptor descriptor = descriptorFor( + providerCode, + registration, + extractorCodes); + return Optional.of(new ResolvedDescriptor( + descriptor, + registration)); + } catch (IllegalArgumentException exception) { + log.warn( + "Identity provider '{}' is hidden because its trusted descriptor is invalid", + providerCode); + return Optional.empty(); + } + } + + private ProviderDescriptor descriptorFor( + String providerCode, + ClientRegistration registration, + Set extractorCodes) { + String issuer = registration.getProviderDetails().getIssuerUri(); + boolean hasIssuer = issuer != null && !issuer.isBlank(); + boolean hasExtractor = extractorCodes.contains(providerCode); + if (hasIssuer && hasExtractor) { + throw new IllegalArgumentException( + "Ambiguous OAuth and OIDC registration"); + } + + return switch (providerCode) { + case "github" -> { + if (!hasExtractor || hasIssuer) { + throw new IllegalArgumentException( + "GitHub extractor is unavailable"); + } + validatePublicGithubEndpoints(registration); + yield descriptor( + providerCode, + "oauth2-github", + "https://github.com", + registration.getClientName(), + "github_user_id", + SubjectCanonicalizer.DECIMAL, + List.of("login"), + List.of("email"), + List.of("avatar_url")); + } + case "gitlab" -> { + if (!hasExtractor || hasIssuer) { + throw new IllegalArgumentException( + "GitLab extractor is unavailable"); + } + String authority = deriveGitlabAuthority(registration); + yield descriptor( + providerCode, + "oauth2-gitlab", + authority, + registration.getClientName(), + "gitlab_user_id", + SubjectCanonicalizer.DECIMAL, + List.of("username", "login"), + List.of("email"), + List.of("avatar_url")); + } + default -> { + if (!hasIssuer || hasExtractor) { + throw new IllegalArgumentException( + "Unsupported OAuth registration"); + } + validateIssuer(issuer); + yield descriptor( + providerCode, + "oidc", + issuer, + registration.getClientName(), + "oidc_sub", + SubjectCanonicalizer.EXACT, + List.of( + "preferred_username", + "name", + "email", + "sub"), + List.of("email"), + List.of("picture", "avatar_url")); + } + }; + } + + private ProviderDescriptor descriptor( + String providerCode, + String protocol, + String authority, + String configuredDisplayName, + String subjectType, + SubjectCanonicalizer canonicalizer, + List displayNameAttributes, + List emailAttributes, + List avatarAttributes) { + String displayName = + configuredDisplayName == null + || configuredDisplayName.isBlank() + ? providerCode + : configuredDisplayName; + return new ProviderDescriptor( + providerCode, + protocol, + authority, + displayName, + subjectType, + Set.of(subjectType), + canonicalizer, + displayNameAttributes, + emailAttributes, + avatarAttributes, + EmailAssurance.VERIFIED); + } + + private void validatePublicGithubEndpoints( + ClientRegistration registration) { + var details = registration.getProviderDetails(); + if (!"https://github.com/login/oauth/authorize".equals( + details.getAuthorizationUri()) + || !"https://github.com/login/oauth/access_token".equals( + details.getTokenUri()) + || !"https://api.github.com/user".equals( + details.getUserInfoEndpoint().getUri())) { + throw new IllegalArgumentException( + "Only public GitHub is supported by the built-in adapter"); + } + } + + private String deriveGitlabAuthority( + ClientRegistration registration) { + String authorizationUri = + registration.getProviderDetails().getAuthorizationUri(); + URI parsed = parseAuthorityUri(authorizationUri); + String suffix = "/oauth/authorize"; + if (!parsed.getPath().endsWith(suffix)) { + throw new IllegalArgumentException( + "Invalid GitLab authorization endpoint"); + } + String authorityPath = parsed.getPath().substring( + 0, + parsed.getPath().length() - suffix.length()); + try { + URI authorityUri = new URI( + parsed.getScheme(), + null, + parsed.getHost(), + parsed.getPort(), + authorityPath.isEmpty() ? null : authorityPath, + null, + null); + String authority = authorityUri.toString(); + validateAuthorityScheme(authorityUri); + if (!(authority + "/oauth/token").equals( + registration.getProviderDetails().getTokenUri()) + || !(authority + "/api/v4/user").equals( + registration.getProviderDetails() + .getUserInfoEndpoint() + .getUri())) { + throw new IllegalArgumentException( + "GitLab endpoints do not share one authority"); + } + return authority; + } catch (URISyntaxException exception) { + throw new IllegalArgumentException( + "Invalid GitLab authority", + exception); + } + } + + private void validateIssuer(String issuer) { + URI uri = parseAuthorityUri(issuer); + validateAuthorityScheme(uri); + } + + private URI parseAuthorityUri(String value) { + try { + URI uri = new URI(value); + if (!uri.isAbsolute() + || uri.getHost() == null + || uri.getRawUserInfo() != null + || uri.getRawQuery() != null + || uri.getRawFragment() != null) { + throw new IllegalArgumentException( + "Invalid authority URI"); + } + return uri; + } catch (URISyntaxException exception) { + throw new IllegalArgumentException( + "Invalid authority URI", + exception); + } + } + + private void validateAuthorityScheme(URI uri) { + if ("https".equalsIgnoreCase(uri.getScheme())) { + return; + } + String host = uri.getHost().toLowerCase(Locale.ROOT); + if ("http".equalsIgnoreCase(uri.getScheme()) + && ("localhost".equals(host) + || "127.0.0.1".equals(host) + || "::1".equals(host))) { + return; + } + throw new IllegalArgumentException( + "Provider authority must use HTTPS"); + } + + private boolean hasRealClientId(String clientId) { + return clientId != null + && !clientId.isBlank() + && !clientId.toLowerCase(Locale.ROOT) + .contains("placeholder"); + } + + private IdentityCoreException providerDisabled() { + return new IdentityCoreException( + IdentityFailureCode.PROVIDER_DISABLED); + } + + private record ResolvedDescriptor( + ProviderDescriptor descriptor, + ClientRegistration registration) { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/SubjectCandidate.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/SubjectCandidate.java new file mode 100644 index 00000000..56c783e9 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/SubjectCandidate.java @@ -0,0 +1,31 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Candidate stable identifier produced by a protocol adapter before the + * identity core applies trusted type and canonicalization rules. + */ +public record SubjectCandidate( + String type, + String value +) { + private static final Pattern TYPE_PATTERN = + Pattern.compile("[a-z][a-z0-9_]{0,63}"); + private static final int ADAPTER_VALUE_LIMIT = 4_096; + + public SubjectCandidate { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(value, "value"); + if (!TYPE_PATTERN.matcher(type).matches()) { + throw new IllegalArgumentException("Invalid subject candidate type"); + } + if (value.isBlank()) { + throw new IllegalArgumentException("Subject candidate value must not be blank"); + } + if (value.length() > ADAPTER_VALUE_LIMIT) { + throw new IllegalArgumentException("Subject candidate value is too long"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/SubjectCanonicalizer.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/SubjectCanonicalizer.java new file mode 100644 index 00000000..e02e0d31 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/SubjectCanonicalizer.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.regex.Pattern; + +enum SubjectCanonicalizer { + EXACT { + @Override + String canonicalize(String value) { + validateCommon(value); + return value; + } + }, + DECIMAL { + private final Pattern decimal = Pattern.compile("[0-9]+"); + + @Override + String canonicalize(String value) { + validateCommon(value); + if (!decimal.matcher(value).matches()) { + throw invalidAssertion(); + } + return value; + } + }; + + abstract String canonicalize(String value); + + static void validateCommon(String value) { + if (value == null + || value.isBlank() + || value.length() > ProviderAssertionLimits.MAX_SUBJECT_VALUE_LENGTH + || value.chars().anyMatch(Character::isISOControl)) { + throw invalidAssertion(); + } + } + + private static IdentityCoreException invalidAssertion() { + return new IdentityCoreException(IdentityFailureCode.INVALID_IDENTITY_ASSERTION); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/TrustedProviderDescriptorSource.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/TrustedProviderDescriptorSource.java new file mode 100644 index 00000000..0dd7726b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/TrustedProviderDescriptorSource.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.List; + +interface TrustedProviderDescriptorSource { + + ProviderDescriptor require(ResolvedProviderHandle provider); + + List enabledDescriptors(); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/TrustedProviderRouteResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/TrustedProviderRouteResolver.java new file mode 100644 index 00000000..ba508682 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/TrustedProviderRouteResolver.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.auth.identity; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; + +/** + * Resolves only server-owned Spring Security client registrations into opaque + * identity-core provider handles. + */ +public interface TrustedProviderRouteResolver { + + ResolvedProviderHandle resolve(ClientRegistration registration); +} 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 df0b1867..e1ebdb86 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 @@ -1,9 +1,10 @@ package com.iflytek.skillhub.auth.local; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.identity.AccountLoginDecision; +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.rbac.PlatformRoleDefaults; -import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; @@ -12,10 +13,8 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.Locale; -import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; -import java.util.stream.Collectors; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -39,26 +38,29 @@ public class LocalAuthService { private final LocalCredentialRepository credentialRepository; private final UserAccountRepository userAccountRepository; - private final UserRoleBindingRepository userRoleBindingRepository; private final GlobalNamespaceMembershipService globalNamespaceMembershipService; private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; private final Clock clock; + private final AccountLoginGuard accountLoginGuard; + private final PlatformPrincipalFactory principalFactory; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, - UserRoleBindingRepository userRoleBindingRepository, GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder, - Clock clock) { + Clock clock, + AccountLoginGuard accountLoginGuard, + PlatformPrincipalFactory principalFactory) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; - this.userRoleBindingRepository = userRoleBindingRepository; this.globalNamespaceMembershipService = globalNamespaceMembershipService; this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; + this.accountLoginGuard = accountLoginGuard; + this.principalFactory = principalFactory; } /** @@ -101,7 +103,7 @@ public class LocalAuthService { )); globalNamespaceMembershipService.ensureMember(user.getId()); - return buildPrincipal(user); + return principalFactory.create(user, "local"); } /** @@ -122,7 +124,8 @@ public class LocalAuthService { UserAccount user = userAccountRepository.findById(credential.getUserId()) .orElseThrow(() -> new IllegalStateException("User not found for local credential")); - ensureUserCanLogin(user); + requireLocalLoginAllowed( + accountLoginGuard.evaluateInteractive(user)); ensureNotLocked(credential); if (!passwordEncoder.matches(password, credential.getPasswordHash())) { @@ -133,7 +136,7 @@ public class LocalAuthService { credential.setFailedAttempts(0); credential.setLockedUntil(null); credentialRepository.save(credential); - return buildPrincipal(user); + return principalFactory.create(user, "local"); } /** @@ -159,30 +162,16 @@ public class LocalAuthService { credentialRepository.save(credential); } - private PlatformPrincipal buildPrincipal(UserAccount user) { - Set roles = userRoleBindingRepository.findByUserId(user.getId()).stream() - .map(binding -> binding.getRole().getCode()) - .collect(Collectors.toSet()); - roles = PlatformRoleDefaults.withDefaultUserRole(roles); - return new PlatformPrincipal( - user.getId(), - user.getDisplayName(), - user.getEmail(), - user.getAvatarUrl(), - "local", - roles - ); - } - - private void ensureUserCanLogin(UserAccount user) { - if (user.getStatus() == UserStatus.DISABLED) { - throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountDisabled"); - } - if (user.getStatus() == UserStatus.PENDING) { - throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountPending"); - } - if (user.getStatus() == UserStatus.MERGED) { - throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountMerged"); + private void requireLocalLoginAllowed(AccountLoginDecision decision) { + String messageKey = switch (decision) { + case ALLOWED -> null; + case DISABLED -> "error.auth.local.accountDisabled"; + case PENDING -> "error.auth.local.accountPending"; + case MERGED -> "error.auth.local.accountMerged"; + case SYSTEM_ACCOUNT -> "error.auth.local.systemAccount"; + }; + if (messageKey != null) { + throw new AuthFlowException(HttpStatus.FORBIDDEN, messageKey); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java index 20ca3fd6..cccda0cd 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java @@ -20,14 +20,21 @@ import org.springframework.stereotype.Service; public class CustomOAuth2UserService implements OAuth2UserService { private final OAuthLoginFlowService oauthLoginFlowService; + private final OAuthIdentityLoginContextResolver contextResolver; - public CustomOAuth2UserService(OAuthLoginFlowService oauthLoginFlowService) { + public CustomOAuth2UserService( + OAuthLoginFlowService oauthLoginFlowService, + OAuthIdentityLoginContextResolver contextResolver) { this.oauthLoginFlowService = oauthLoginFlowService; + this.contextResolver = contextResolver; } @Override public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { - OAuthLoginFlowService.AuthenticatedLoginContext context = oauthLoginFlowService.loadLoginContext(request); + OAuthLoginFlowService.AuthenticatedLoginContext context = + oauthLoginFlowService.loadLoginContext( + request, + contextResolver.current()); PlatformPrincipal principal = context.principal(); var attrs = new HashMap<>(context.upstreamUser().getAttributes()); attrs.put("platformPrincipal", principal); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java index 9dc967b5..dc9e6804 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserService.java @@ -1,9 +1,17 @@ package com.iflytek.skillhub.auth.oauth; +import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; +import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; +import com.iflytek.skillhub.auth.identity.ProviderAttributeValue; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.SubjectCandidate; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import java.util.HashMap; import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -30,34 +38,51 @@ public class CustomOidcUserService implements OAuth2UserService delegate; + private final OAuthIdentityLoginContextResolver contextResolver; @Autowired - public CustomOidcUserService(OAuthLoginFlowService oauthLoginFlowService) { - this(oauthLoginFlowService, new OidcUserService()); + public CustomOidcUserService( + OAuthLoginFlowService oauthLoginFlowService, + OAuthIdentityLoginContextResolver contextResolver) { + this( + oauthLoginFlowService, + new OidcUserService(), + contextResolver); } CustomOidcUserService(OAuthLoginFlowService oauthLoginFlowService, - OAuth2UserService delegate) { + OAuth2UserService delegate, + OAuthIdentityLoginContextResolver contextResolver) { this.oauthLoginFlowService = oauthLoginFlowService; this.delegate = delegate; + this.contextResolver = contextResolver; } @Override public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException { String registrationId = request.getClientRegistration().getRegistrationId(); log.debug("OIDC login initiated for registration '{}'", registrationId); + var loginContext = contextResolver.current(); OidcUser upstreamUser = delegate.loadUser(request); - OAuthClaims claims = toOAuthClaims(request, upstreamUser); - log.debug("OIDC claims extracted - provider: {}, subject: {}, email present: {}, emailVerified: {}", - claims.provider(), claims.subject(), claims.email() != null, claims.emailVerified()); + ProviderAuthenticationResult result = + toProviderAuthenticationResult(request, upstreamUser); + log.debug( + "OIDC identity facts extracted - registration: {}, subject type: {}", + registrationId, + result.primarySubject().type()); PlatformPrincipal principal; try { - principal = oauthLoginFlowService.authenticate(claims); + principal = oauthLoginFlowService.authenticate( + request.getClientRegistration(), + result, + loginContext); } catch (OAuth2AuthenticationException e) { - log.warn("OIDC authentication failed for registration '{}', subject '{}': {}", - registrationId, claims.subject(), e.getMessage(), e); + log.warn( + "OIDC authentication failed for registration '{}': {}", + registrationId, + e.getError().getErrorCode()); throw e; } log.debug("OIDC authentication succeeded - userId: {}, roles: {}", @@ -83,7 +108,9 @@ public class CustomOidcUserService implements OAuth2UserService claims = new HashMap<>(oidcUser.getClaims()); String subject = asString(claims.get("sub")); if (subject == null || subject.isBlank()) { @@ -92,39 +119,49 @@ public class CustomOidcUserService implements OAuth2UserService> attributes = + new LinkedHashMap<>(); + put(attributes, "preferred_username", claims.get("preferred_username"), + ProviderAttributeTrust.ASSERTED); + put(attributes, "name", claims.get("name"), + ProviderAttributeTrust.ASSERTED); + put( + attributes, + "email", email, - subject - ); - if (claims.get("picture") != null && claims.get("avatar_url") == null) { - claims.put("avatar_url", claims.get("picture")); - } + emailVerified + ? ProviderAttributeTrust.VERIFIED + : ProviderAttributeTrust.UNVERIFIED); + put(attributes, "sub", subject, ProviderAttributeTrust.ASSERTED); + put(attributes, "picture", claims.get("picture"), + ProviderAttributeTrust.ASSERTED); + put(attributes, "avatar_url", claims.get("avatar_url"), + ProviderAttributeTrust.ASSERTED); - return new OAuthClaims( - request.getClientRegistration().getRegistrationId(), - subject, - email, - emailVerified, - providerLogin, - claims - ); - } - - private static String firstPresent(String... values) { - for (String value : values) { - if (value != null && !value.isBlank()) { - return value; - } - } - return null; + return new ProviderAuthenticationResult( + new SubjectCandidate("oidc_sub", subject), + List.of(), + attributes, + new ProtocolAuthenticationEvidence( + "oidc", + oidcUser.getIdToken().getIssuedAt(), + Set.of("oidc_authorization_code"))); } private static String asString(Object value) { return value instanceof String str ? str : null; } + + private static void put( + Map> attributes, + String key, + Object rawValue, + ProviderAttributeTrust trust) { + if (!(rawValue instanceof String value) || value.isBlank()) { + return; + } + attributes.put( + key, + List.of(new ProviderAttributeValue(value, trust))); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java index b165e1fa..ebacfa6d 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java @@ -1,5 +1,10 @@ package com.iflytek.skillhub.auth.oauth; +import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; +import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; +import com.iflytek.skillhub.auth.identity.ProviderAttributeValue; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.SubjectCandidate; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; @@ -8,8 +13,10 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * Provider-specific claims extractor that enriches GitHub OAuth users with their primary verified @@ -31,20 +38,53 @@ public class GitHubClaimsExtractor implements OAuthClaimsExtractor { public String getProvider() { return "github"; } @Override - public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { + public ProviderAuthenticationResult extract( + OAuth2UserRequest request, + OAuth2User oAuth2User) { Map attrs = oAuth2User.getAttributes(); GitHubEmail primaryEmail = loadPrimaryEmail(request); String email = primaryEmail != null ? primaryEmail.email() : (String) attrs.get("email"); boolean emailVerified = primaryEmail != null && primaryEmail.verified(); - return new OAuthClaims( - "github", - String.valueOf(attrs.get("id")), - email, - emailVerified, - (String) attrs.get("login"), - attrs - ); + Map> attributes = + new LinkedHashMap<>(); + put(attributes, "login", attrs.get("login"), ProviderAttributeTrust.ASSERTED); + put( + attributes, + "email", + email, + emailVerified + ? ProviderAttributeTrust.VERIFIED + : ProviderAttributeTrust.UNVERIFIED); + put( + attributes, + "avatar_url", + attrs.get("avatar_url"), + ProviderAttributeTrust.ASSERTED); + + return new ProviderAuthenticationResult( + new SubjectCandidate( + "github_user_id", + String.valueOf(attrs.get("id"))), + List.of(), + attributes, + new ProtocolAuthenticationEvidence( + "oauth2-github", + request.getAccessToken().getIssuedAt(), + Set.of("oauth2_authorization_code"))); + } + + private void put( + Map> attributes, + String key, + Object rawValue, + ProviderAttributeTrust trust) { + if (!(rawValue instanceof String value) || value.isBlank()) { + return; + } + attributes.put( + key, + List.of(new ProviderAttributeValue(value, trust))); } private GitHubEmail loadPrimaryEmail(OAuth2UserRequest request) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java index d6c840a2..02019fb3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java @@ -1,6 +1,11 @@ package com.iflytek.skillhub.auth.oauth; import com.fasterxml.jackson.annotation.JsonProperty; +import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; +import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; +import com.iflytek.skillhub.auth.identity.ProviderAttributeValue; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.SubjectCandidate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; @@ -11,7 +16,9 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import java.util.List; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; /** * Provider-specific claims extractor that enriches GitLab OAuth users with their @@ -39,7 +46,9 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor { } @Override - public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { + public ProviderAuthenticationResult extract( + OAuth2UserRequest request, + OAuth2User oAuth2User) { Map attrs = oAuth2User.getAttributes(); log.debug("Extracting GitLab OAuth claims for user attributes: {}", attrs.keySet()); @@ -73,14 +82,47 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor { log.info("GitLab OAuth claims extracted - subject: {}, username: {}, email: {}, emailVerified: {}", subject, username, email, emailVerified); - return new OAuthClaims( - "gitlab", - subject, - email, - emailVerified, - username, - attrs - ); + Map> attributes = + new LinkedHashMap<>(); + put( + attributes, + "username", + username, + ProviderAttributeTrust.ASSERTED); + put( + attributes, + "email", + email, + emailVerified + ? ProviderAttributeTrust.VERIFIED + : ProviderAttributeTrust.UNVERIFIED); + put( + attributes, + "avatar_url", + attrs.get("avatar_url"), + ProviderAttributeTrust.ASSERTED); + + return new ProviderAuthenticationResult( + new SubjectCandidate("gitlab_user_id", subject), + List.of(), + attributes, + new ProtocolAuthenticationEvidence( + "oauth2-gitlab", + request.getAccessToken().getIssuedAt(), + Set.of("oauth2_authorization_code"))); + } + + private void put( + Map> attributes, + String key, + Object rawValue, + ProviderAttributeTrust trust) { + if (!(rawValue instanceof String value) || value.isBlank()) { + return; + } + attributes.put( + key, + List.of(new ProviderAttributeValue(value, trust))); } private GitLabEmail loadPrimaryEmail(OAuth2UserRequest request) { 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 new file mode 100644 index 00000000..8ead2c02 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilter.java @@ -0,0 +1,112 @@ +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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Rejects unavailable OAuth/OIDC routes before Spring Security performs an + * upstream redirect, token exchange, or user-info request. + */ +public final class IdentityProviderRouteReadinessFilter + extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger( + IdentityProviderRouteReadinessFilter.class); + private static final String AUTHORIZATION_PREFIX = + "/oauth2/authorization/"; + private static final String CALLBACK_PREFIX = + "/login/oauth2/code/"; + private final ClientRegistrationRepository registrationRepository; + private final IdentityProviderReadinessService readinessService; + + public IdentityProviderRouteReadinessFilter( + ClientRegistrationRepository registrationRepository, + IdentityProviderReadinessService readinessService) { + this.registrationRepository = registrationRepository; + this.readinessService = readinessService; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = pathWithinApplication(request); + return !path.startsWith(AUTHORIZATION_PREFIX) + && !path.startsWith(CALLBACK_PREFIX); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + String registrationId = registrationId(request); + ClientRegistration registration = registrationId == null + ? null + : registrationRepository.findByRegistrationId(registrationId); + if (registration == null) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return; + } + + try { + readinessService.requireReady(registration); + } catch (IdentityCoreException exception) { + int status = exception.getReasonCode() + == IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH + ? HttpServletResponse.SC_SERVICE_UNAVAILABLE + : HttpServletResponse.SC_FORBIDDEN; + log.warn( + "Identity provider route '{}' rejected before upstream I/O: {}", + registration.getRegistrationId(), + exception.getReasonCode()); + response.setStatus(status); + return; + } catch (RuntimeException exception) { + log.error( + "Identity provider route '{}' readiness check failed", + registration.getRegistrationId(), + exception); + response.setStatus( + HttpServletResponse.SC_SERVICE_UNAVAILABLE); + return; + } + filterChain.doFilter(request, response); + } + + private String registrationId(HttpServletRequest request) { + String path = pathWithinApplication(request); + String value = pathSegment(path, AUTHORIZATION_PREFIX); + return value != null + ? value + : pathSegment(path, CALLBACK_PREFIX); + } + + private String pathWithinApplication(HttpServletRequest request) { + String requestUri = request.getRequestURI(); + String contextPath = request.getContextPath(); + return contextPath.isEmpty() + ? requestUri + : requestUri.substring(contextPath.length()); + } + + private String pathSegment(String path, String prefix) { + if (!path.startsWith(prefix)) { + return null; + } + String value = path.substring(prefix.length()); + return value.isEmpty() || value.indexOf('/') >= 0 + ? null + : value; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java deleted file mode 100644 index 78dc9b80..00000000 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.iflytek.skillhub.auth.oauth; - -import java.util.Map; - -/** - * Normalized identity claims extracted from an OAuth provider before local account decisions are - * made. - */ -public record OAuthClaims( - String provider, - String subject, - String email, - boolean emailVerified, - String providerLogin, - Map extra -) {} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java index 64b75ec8..dec2bb07 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.auth.oauth; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.user.OAuth2User; @@ -8,5 +9,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; */ public interface OAuthClaimsExtractor { String getProvider(); - OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User); + ProviderAuthenticationResult extract( + OAuth2UserRequest request, + OAuth2User oAuth2User); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthIdentityLoginContextResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthIdentityLoginContextResolver.java new file mode 100644 index 00000000..5c97cc61 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthIdentityLoginContextResolver.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * Extracts bounded, non-sensitive audit metadata from the active OAuth/OIDC + * callback request. + */ +@Component +class OAuthIdentityLoginContextResolver { + + private static final String REQUEST_ID_MDC_KEY = "requestId"; + + IdentityLoginContext current() { + if (!(RequestContextHolder.getRequestAttributes() + instanceof ServletRequestAttributes attributes)) { + return IdentityLoginContext.empty(); + } + HttpServletRequest request = attributes.getRequest(); + return new IdentityLoginContext( + bounded(MDC.get(REQUEST_ID_MDC_KEY), 64), + bounded(request.getRemoteAddr(), 64), + bounded(request.getHeader("User-Agent"), 512)); + } + + private String bounded(String value, int maximum) { + return value == null || value.length() > maximum + ? null + : value; + } +} 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 b8b71d89..c8de5dae 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,10 +1,14 @@ package com.iflytek.skillhub.auth.oauth; -import com.iflytek.skillhub.auth.identity.IdentityBindingService; -import com.iflytek.skillhub.auth.policy.AccessDecision; -import com.iflytek.skillhub.auth.policy.AccessPolicy; +import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService; +import com.iflytek.skillhub.auth.identity.IdentityCoreException; +import com.iflytek.skillhub.auth.identity.IdentityFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle; +import com.iflytek.skillhub.auth.identity.TrustedProviderRouteResolver; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.domain.user.UserStatus; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import java.net.URLEncoder; @@ -14,6 +18,7 @@ import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; @@ -31,19 +36,21 @@ public class OAuthLoginFlowService { private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); private final Map extractors; - private final AccessPolicy accessPolicy; - private final IdentityBindingService identityBindingService; + private final TrustedProviderRouteResolver providerRouteResolver; + private final ExternalIdentityLoginService identityLoginService; public OAuthLoginFlowService(List extractorList, - AccessPolicy accessPolicy, - IdentityBindingService identityBindingService) { + TrustedProviderRouteResolver providerRouteResolver, + ExternalIdentityLoginService identityLoginService) { this.extractors = extractorList.stream() .collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity())); - this.accessPolicy = accessPolicy; - this.identityBindingService = identityBindingService; + this.providerRouteResolver = providerRouteResolver; + this.identityLoginService = identityLoginService; } - public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) { + public AuthenticatedLoginContext loadLoginContext( + OAuth2UserRequest request, + IdentityLoginContext context) { OAuth2User upstreamUser = delegate.loadUser(request); String registrationId = request.getClientRegistration().getRegistrationId(); @@ -54,24 +61,39 @@ public class OAuthLoginFlowService { ); } - OAuthClaims claims = extractor.extract(request, upstreamUser); - PlatformPrincipal principal = authenticate(claims); + ProviderAuthenticationResult result = + extractor.extract(request, upstreamUser); + PlatformPrincipal principal = authenticate( + request.getClientRegistration(), + result, + context); return new AuthenticatedLoginContext(upstreamUser, principal); } - public PlatformPrincipal authenticate(OAuthClaims claims) { - AccessDecision decision = accessPolicy.evaluate(claims); - - if (decision == AccessDecision.PENDING_APPROVAL) { - return identityBindingService.bindOrCreate(claims, UserStatus.PENDING); + public PlatformPrincipal authenticate( + ClientRegistration registration, + ProviderAuthenticationResult result, + IdentityLoginContext context) { + try { + ResolvedProviderHandle provider = + providerRouteResolver.resolve(registration); + IdentityLoginOutcome outcome = identityLoginService.authenticate( + provider, + result, + context); + if (outcome instanceof IdentityLoginOutcome.Authenticated authenticated) { + return authenticated.principal(); + } + if (outcome instanceof IdentityLoginOutcome.PendingApproval) { + throw new AccountPendingException(); + } + throw new OAuth2AuthenticationException(new OAuth2Error( + "link_required", + "Additional account verification is required", + null)); + } catch (IdentityCoreException exception) { + throw mapIdentityFailure(exception); } - if (decision == AccessDecision.DENY) { - throw new OAuth2AuthenticationException( - new OAuth2Error("access_denied", "Access denied by policy", null) - ); - } - - return identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE); } public void rememberReturnTo(HttpServletRequest request) { @@ -106,6 +128,17 @@ public class OAuthLoginFlowService { && "access_denied".equals(oauth2Exception.getError().getErrorCode())) { return "/access-denied"; } + if (exception instanceof OAuth2AuthenticationException oauth2Exception + && ("provider_authority_mismatch".equals( + oauth2Exception.getError().getErrorCode()) + || "provider_disabled".equals( + oauth2Exception.getError().getErrorCode()) + || "invalid_identity_assertion".equals( + oauth2Exception.getError().getErrorCode()) + || "link_required".equals( + oauth2Exception.getError().getErrorCode()))) { + return "/access-denied"; + } if (returnTo != null) { return "/login?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8); } @@ -114,4 +147,42 @@ public class OAuthLoginFlowService { public record AuthenticatedLoginContext(OAuth2User upstreamUser, PlatformPrincipal principal) { } + + private AuthenticationException mapIdentityFailure( + IdentityCoreException exception) { + return switch (exception.getReasonCode()) { + case ACCOUNT_PENDING -> new AccountPendingException(); + case ACCOUNT_DISABLED -> new AccountDisabledException(); + case ACCOUNT_MERGED -> new AccountMergedException(); + case SYSTEM_ACCOUNT_FORBIDDEN -> + new SystemAccountLoginException(); + case ACCESS_DENIED -> oauthFailure( + "access_denied", + "Access denied by policy", + exception); + case PROVIDER_AUTHORITY_MISMATCH -> oauthFailure( + "provider_authority_mismatch", + "Identity provider authority mismatch", + exception); + case PROVIDER_DISABLED -> oauthFailure( + "provider_disabled", + "Identity provider is unavailable", + exception); + case INVALID_IDENTITY_ASSERTION, + IDENTITY_SUBJECT_MISSING, + IDENTITY_IDENTIFIER_CONFLICT -> oauthFailure( + "invalid_identity_assertion", + "External identity assertion was rejected", + exception); + }; + } + + private OAuth2AuthenticationException oauthFailure( + String code, + String description, + RuntimeException cause) { + return new OAuth2AuthenticationException( + new OAuth2Error(code, description, null), + cause); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java index 8ac38ea6..208f751b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java @@ -1,10 +1,8 @@ package com.iflytek.skillhub.auth.policy; -import com.iflytek.skillhub.auth.oauth.OAuthClaims; - /** * Policy contract for deciding whether externally authenticated users may enter the platform. */ public interface AccessPolicy { - AccessDecision evaluate(OAuthClaims claims); + AccessDecision evaluate(IdentityAccessContext context); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java index b65a738d..26058462 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java @@ -1,6 +1,6 @@ package com.iflytek.skillhub.auth.policy; -import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import java.util.Locale; import java.util.Set; /** @@ -14,10 +14,18 @@ public class EmailDomainAccessPolicy implements AccessPolicy { } @Override - public AccessDecision evaluate(OAuthClaims claims) { - if (claims.email() == null || !claims.emailVerified()) return AccessDecision.DENY; - String domain = claims.email().substring(claims.email().indexOf('@') + 1); - return allowedDomains.contains(domain.toLowerCase()) + public AccessDecision evaluate(IdentityAccessContext context) { + if (context.email().isEmpty() + || !context.emailAssurance().isVerifiedOrAuthoritative()) { + return AccessDecision.DENY; + } + String email = context.email().orElseThrow(); + int separator = email.lastIndexOf('@'); + if (separator <= 0 || separator == email.length() - 1) { + return AccessDecision.DENY; + } + String domain = email.substring(separator + 1); + return allowedDomains.contains(domain.toLowerCase(Locale.ROOT)) ? AccessDecision.ALLOW : AccessDecision.DENY; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/IdentityAccessContext.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/IdentityAccessContext.java new file mode 100644 index 00000000..3cd64ebc --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/IdentityAccessContext.java @@ -0,0 +1,27 @@ +package com.iflytek.skillhub.auth.policy; + +import com.iflytek.skillhub.auth.identity.EmailAssurance; +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import java.util.Objects; +import java.util.Optional; + +/** + * Protocol-neutral facts available to external-login access policies. + */ +public record IdentityAccessContext( + String providerCode, + String subjectType, + String subjectValue, + Optional email, + EmailAssurance emailAssurance, + IdentityLoginContext requestContext +) { + public IdentityAccessContext { + Objects.requireNonNull(providerCode, "providerCode"); + Objects.requireNonNull(subjectType, "subjectType"); + Objects.requireNonNull(subjectValue, "subjectValue"); + Objects.requireNonNull(email, "email"); + Objects.requireNonNull(emailAssurance, "emailAssurance"); + Objects.requireNonNull(requestContext, "requestContext"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java index 9febc325..07b92c5a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java @@ -1,13 +1,11 @@ package com.iflytek.skillhub.auth.policy; -import com.iflytek.skillhub.auth.oauth.OAuthClaims; - /** * Access policy that accepts all OAuth-authenticated users. */ public class OpenAccessPolicy implements AccessPolicy { @Override - public AccessDecision evaluate(OAuthClaims claims) { + public AccessDecision evaluate(IdentityAccessContext context) { return AccessDecision.ALLOW; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java index d44ec348..a99d0a0d 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java @@ -1,6 +1,5 @@ package com.iflytek.skillhub.auth.policy; -import com.iflytek.skillhub.auth.oauth.OAuthClaims; import java.util.Set; /** @@ -14,8 +13,8 @@ public class ProviderAllowlistAccessPolicy implements AccessPolicy { } @Override - public AccessDecision evaluate(OAuthClaims claims) { - return allowedProviders.contains(claims.provider()) + public AccessDecision evaluate(IdentityAccessContext context) { + return allowedProviders.contains(context.providerCode()) ? AccessDecision.ALLOW : AccessDecision.DENY; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java index 0b9546f5..b13108d2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java @@ -1,6 +1,5 @@ package com.iflytek.skillhub.auth.policy; -import com.iflytek.skillhub.auth.oauth.OAuthClaims; import java.util.Set; /** @@ -14,8 +13,8 @@ public class SubjectWhitelistAccessPolicy implements AccessPolicy { } @Override - public AccessDecision evaluate(OAuthClaims claims) { - String key = claims.provider() + ":" + claims.subject(); + public AccessDecision evaluate(IdentityAccessContext context) { + String key = context.providerCode() + ":" + context.subjectValue(); return whitelistedSubjects.contains(key) ? AccessDecision.ALLOW : AccessDecision.DENY; } 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 22499604..94dc3cfe 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 @@ -11,5 +11,6 @@ import java.util.Optional; @Repository public interface IdentityBindingRepository extends JpaRepository { Optional findByProviderCodeAndSubject(String providerCode, String subject); + boolean existsByProviderCode(String providerCode); java.util.List findByUserId(String userId); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/AccountLoginGuardTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/AccountLoginGuardTest.java new file mode 100644 index 00000000..33c23855 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/AccountLoginGuardTest.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.junit.jupiter.api.Test; + +class AccountLoginGuardTest { + + private final AccountLoginGuard guard = new AccountLoginGuard(); + + @Test + void allowsActiveInteractiveAccount() { + assertThat(guard.evaluateInteractive(user(UserStatus.ACTIVE))) + .isEqualTo(AccountLoginDecision.ALLOWED); + } + + @Test + void classifiesAllBlockedInteractiveAccountStates() { + assertThat(guard.evaluateInteractive(user(UserStatus.PENDING))) + .isEqualTo(AccountLoginDecision.PENDING); + assertThat(guard.evaluateInteractive(user(UserStatus.DISABLED))) + .isEqualTo(AccountLoginDecision.DISABLED); + assertThat(guard.evaluateInteractive(user(UserStatus.MERGED))) + .isEqualTo(AccountLoginDecision.MERGED); + assertThat(guard.evaluateInteractive( + UserAccount.systemAccount("system_1", "system", null, null))) + .isEqualTo(AccountLoginDecision.SYSTEM_ACCOUNT); + } + + private static UserAccount user(UserStatus status) { + UserAccount user = new UserAccount( + "usr_1", + "alice", + "alice@example.com", + null); + user.setStatus(status); + return user; + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginServiceTest.java new file mode 100644 index 00000000..2b0d59d3 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginServiceTest.java @@ -0,0 +1,170 @@ +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.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.policy.AccessDecision; +import com.iflytek.skillhub.auth.policy.AccessPolicy; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.time.Instant; +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.mockito.InOrder; +import org.mockito.ArgumentCaptor; + +class DefaultExternalIdentityLoginServiceTest { + + private final ProviderDescriptor descriptor = descriptor(); + private TrustedProviderDescriptorSource descriptorSource; + private ProviderAuthorityLockService authorityLockService; + private AccessPolicy accessPolicy; + private IdentityResolutionTransaction resolutionTransaction; + private DefaultExternalIdentityLoginService service; + + @BeforeEach + void setUp() { + descriptorSource = mock(TrustedProviderDescriptorSource.class); + authorityLockService = mock(ProviderAuthorityLockService.class); + accessPolicy = mock(AccessPolicy.class); + resolutionTransaction = mock(IdentityResolutionTransaction.class); + service = new DefaultExternalIdentityLoginService( + descriptorSource, + authorityLockService, + new IdentityAssertionFactory(), + accessPolicy, + resolutionTransaction); + } + + @Test + void resolvesTrustedProviderBeforeAuthorityAssertionPolicyAndTransaction() { + ResolvedProviderHandle handle = + new DefaultResolvedProviderHandle("github"); + ProviderAuthenticationResult result = result(); + IdentityLoginContext context = IdentityLoginContext.empty(); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "alice", + "alice@example.com", + null, + "github", + Set.of("USER")); + IdentityLoginOutcome expected = + new IdentityLoginOutcome.Authenticated( + principal, + false, + false); + when(descriptorSource.require(handle)).thenReturn(descriptor); + when(accessPolicy.evaluate(any())).thenReturn(AccessDecision.ALLOW); + when(resolutionTransaction.resolve( + any(IdentityAssertion.class), + org.mockito.ArgumentMatchers.eq(UserStatus.ACTIVE))) + .thenReturn(expected); + + IdentityLoginOutcome outcome = + service.authenticate(handle, result, context); + + assertThat(outcome).isSameAs(expected); + ArgumentCaptor + accessContext = ArgumentCaptor.forClass( + com.iflytek.skillhub.auth.policy.IdentityAccessContext.class); + verify(accessPolicy).evaluate(accessContext.capture()); + assertThat(accessContext.getValue().requestContext()) + .isSameAs(context); + InOrder order = inOrder( + descriptorSource, + authorityLockService, + accessPolicy, + resolutionTransaction); + order.verify(descriptorSource).require(handle); + order.verify(authorityLockService).requirePinnedAuthority(descriptor); + order.verify(accessPolicy).evaluate(any()); + order.verify(resolutionTransaction).resolve( + any(IdentityAssertion.class), + org.mockito.ArgumentMatchers.eq(UserStatus.ACTIVE)); + } + + @Test + void pendingPolicyUsesCompatibilityPendingProvisioningMode() { + ResolvedProviderHandle handle = + new DefaultResolvedProviderHandle("github"); + when(descriptorSource.require(handle)).thenReturn(descriptor); + when(accessPolicy.evaluate(any())) + .thenReturn(AccessDecision.PENDING_APPROVAL); + IdentityLoginOutcome pending = + new IdentityLoginOutcome.PendingApproval("ACCOUNT_PENDING"); + when(resolutionTransaction.resolve( + any(IdentityAssertion.class), + org.mockito.ArgumentMatchers.eq(UserStatus.PENDING))) + .thenReturn(pending); + + IdentityLoginOutcome outcome = + service.authenticate( + handle, + result(), + IdentityLoginContext.empty()); + + assertThat(outcome).isSameAs(pending); + } + + @Test + void deniedPolicyNeverReachesProvisioningTransaction() { + ResolvedProviderHandle handle = + new DefaultResolvedProviderHandle("github"); + when(descriptorSource.require(handle)).thenReturn(descriptor); + when(accessPolicy.evaluate(any())).thenReturn(AccessDecision.DENY); + + assertThatThrownBy(() -> service.authenticate( + handle, + result(), + IdentityLoginContext.empty())) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.ACCESS_DENIED); + + verify(resolutionTransaction, never()).resolve(any(), any()); + } + + private static ProviderAuthenticationResult result() { + return new ProviderAuthenticationResult( + new SubjectCandidate("github_user_id", "123456"), + List.of(), + Map.of( + "login", + List.of(new ProviderAttributeValue( + "alice", + ProviderAttributeTrust.ASSERTED)), + "email", + List.of(new ProviderAttributeValue( + "alice@example.com", + ProviderAttributeTrust.VERIFIED))), + new ProtocolAuthenticationEvidence( + "oauth2-github", + Instant.parse("2026-07-30T08:00:00Z"), + Set.of("oauth2_authorization_code"))); + } + + private static ProviderDescriptor descriptor() { + return new ProviderDescriptor( + "github", + "oauth2-github", + "https://github.com", + "GitHub", + "github_user_id", + Set.of("github_user_id"), + SubjectCanonicalizer.DECIMAL, + List.of("login"), + List.of("email"), + List.of("avatar_url"), + EmailAssurance.VERIFIED); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderAuthorityOperationsTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderAuthorityOperationsTest.java new file mode 100644 index 00000000..334d60af --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderAuthorityOperationsTest.java @@ -0,0 +1,118 @@ +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.mock; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DefaultIdentityProviderAuthorityOperationsTest { + + private TrustedProviderDescriptorSource descriptorSource; + private ProviderAuthorityLockService authorityLockService; + private DefaultIdentityProviderAuthorityOperations operations; + + @BeforeEach + void setUp() { + descriptorSource = mock(TrustedProviderDescriptorSource.class); + authorityLockService = mock(ProviderAuthorityLockService.class); + operations = new DefaultIdentityProviderAuthorityOperations( + descriptorSource, + authorityLockService); + } + + @Test + void returnsAuditedRecoveryResultForConfiguredAuthority() { + ProviderDescriptor descriptor = descriptor(); + IdentityProviderAuthorityRecoveryContext context = context(); + when(descriptorSource.enabledDescriptors()) + .thenReturn(List.of(descriptor)); + when(authorityLockService.recoverSameAuthority( + descriptor, + context)).thenReturn(new SameAuthorityRecoveryEvaluation( + true, + new AuthorityLockEvaluation( + IdentityProviderStatus.READY, + fingerprint()))); + + IdentityProviderAuthorityRecoveryResult result = + operations.recoverSameAuthority("github", context); + + assertThat(result).isEqualTo( + new IdentityProviderAuthorityRecoveryResult( + "github", + true, + "READY")); + } + + @Test + void rejectsRecoveryWhenConfiguredAuthorityStillDiffers() { + ProviderDescriptor descriptor = descriptor(); + IdentityProviderAuthorityRecoveryContext context = context(); + when(descriptorSource.enabledDescriptors()) + .thenReturn(List.of(descriptor)); + when(authorityLockService.recoverSameAuthority( + descriptor, + context)).thenReturn(new SameAuthorityRecoveryEvaluation( + false, + new AuthorityLockEvaluation( + IdentityProviderStatus.AUTHORITY_MISMATCH, + "a".repeat(64)))); + + assertThatThrownBy(() -> + operations.recoverSameAuthority("github", context)) + .isInstanceOf(AuthFlowException.class) + .extracting("status", "messageCode") + .containsExactly( + org.springframework.http.HttpStatus.CONFLICT, + "error.auth.provider.authorityRecoveryMismatch"); + } + + @Test + void rejectsUnknownProviderWithoutTouchingAuthorityState() { + when(descriptorSource.enabledDescriptors()) + .thenReturn(List.of()); + + assertThatThrownBy(() -> + operations.recoverSameAuthority("unknown", context())) + .isInstanceOf(AuthFlowException.class) + .extracting("status", "messageCode") + .containsExactly( + org.springframework.http.HttpStatus.NOT_FOUND, + "error.auth.provider.notFound"); + } + + private static IdentityProviderAuthorityRecoveryContext context() { + return new IdentityProviderAuthorityRecoveryContext( + "admin", + "req-123", + "203.0.113.9", + "SkillHub Browser"); + } + + private static String fingerprint() { + return ProviderAuthorityFingerprint.sha256( + "oauth2-github", + "https://github.com"); + } + + private static ProviderDescriptor descriptor() { + return new ProviderDescriptor( + "github", + "oauth2-github", + "https://github.com", + "GitHub", + "github_user_id", + Set.of("github_user_id"), + SubjectCanonicalizer.DECIMAL, + List.of("login"), + List.of("email"), + List.of("avatar_url"), + EmailAssurance.VERIFIED); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderReadinessServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderReadinessServiceTest.java new file mode 100644 index 00000000..9e7bc8d1 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultIdentityProviderReadinessServiceTest.java @@ -0,0 +1,80 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; + +class DefaultIdentityProviderReadinessServiceTest { + + @Test + void resolvesServerRouteBeforeCheckingPersistedReadiness() { + TrustedProviderRouteResolver routeResolver = + mock(TrustedProviderRouteResolver.class); + TrustedProviderDescriptorSource descriptorSource = + mock(TrustedProviderDescriptorSource.class); + ProviderAuthorityLockService authorityLockService = + mock(ProviderAuthorityLockService.class); + ClientRegistration registration = registration(); + ResolvedProviderHandle handle = + new DefaultResolvedProviderHandle("github"); + ProviderDescriptor descriptor = descriptor(); + when(routeResolver.resolve(registration)).thenReturn(handle); + when(descriptorSource.require(handle)).thenReturn(descriptor); + DefaultIdentityProviderReadinessService service = + new DefaultIdentityProviderReadinessService( + routeResolver, + descriptorSource, + authorityLockService); + + service.requireReady(registration); + + InOrder order = inOrder( + routeResolver, + descriptorSource, + authorityLockService); + order.verify(routeResolver).resolve(registration); + order.verify(descriptorSource).require(handle); + order.verify(authorityLockService) + .requirePinnedAuthority(descriptor); + } + + private static ProviderDescriptor descriptor() { + return new ProviderDescriptor( + "github", + "oauth2-github", + "https://github.com", + "GitHub", + "github_user_id", + Set.of("github_user_id"), + SubjectCanonicalizer.DECIMAL, + List.of("login"), + List.of("email"), + List.of("avatar_url"), + EmailAssurance.VERIFIED); + } + + private static ClientRegistration registration() { + return ClientRegistration.withRegistrationId("github") + .clientId("client") + .clientSecret("secret") + .authorizationGrantType( + AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri( + "{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri( + "https://github.com/login/oauth/authorize") + .tokenUri( + "https://github.com/login/oauth/access_token") + .userInfoUri("https://api.github.com/user") + .userNameAttributeName("id") + .clientName("GitHub") + .build(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactoryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactoryTest.java new file mode 100644 index 00000000..51700e7e --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityAssertionFactoryTest.java @@ -0,0 +1,226 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class IdentityAssertionFactoryTest { + + private static final Instant AUTHENTICATED_AT = Instant.parse("2026-07-30T08:00:00Z"); + + private final IdentityAssertionFactory factory = new IdentityAssertionFactory(); + + @Test + void createsAssertionFromTrustedDescriptorAndVerifiedProviderFacts() { + ProviderAuthenticationResult result = result( + new SubjectCandidate("github_user_id", "123456"), + List.of(), + Map.of( + "login", values("alice", ProviderAttributeTrust.ASSERTED), + "email", values("alice@example.com", ProviderAttributeTrust.VERIFIED), + "avatar_url", values("https://avatars.example/alice.png", ProviderAttributeTrust.ASSERTED), + "ignored_role", values("SUPER_ADMIN", ProviderAttributeTrust.VERIFIED) + ), + "oauth2-github" + ); + + IdentityAssertion assertion = factory.create(githubDescriptor(), result); + + assertThat(assertion.provider()).isEqualTo( + new ProviderReference("github", "oauth2-github", "https://github.com")); + assertThat(assertion.primarySubject()).isEqualTo( + new ExternalSubject("github_user_id", "123456")); + assertThat(assertion.alternateSubjects()).isEmpty(); + assertThat(assertion.profile().displayName()).isEqualTo("alice"); + assertThat(assertion.profile().email()).contains( + new EmailClaim("alice@example.com", EmailAssurance.VERIFIED)); + assertThat(assertion.profile().avatarUrl()) + .hasValueSatisfying(uri -> assertThat(uri.toString()) + .isEqualTo("https://avatars.example/alice.png")); + assertThat(assertion.mappedAttributes()).isEmpty(); + assertThat(assertion.evidence()).isEqualTo( + new AuthenticationEvidence( + "oauth2-github", + AUTHENTICATED_AT, + Set.of("oauth2_authorization_code"))); + } + + @Test + void clampsEmailTrustToDescriptorAssuranceLimit() { + ProviderDescriptor descriptor = new ProviderDescriptor( + "corp-oidc", + "oidc", + "https://id.example.com", + "Corporate OIDC", + "oidc_sub", + Set.of("oidc_sub"), + SubjectCanonicalizer.EXACT, + List.of("preferred_username", "name", "sub"), + List.of("email"), + List.of("picture"), + EmailAssurance.PROVIDER_ASSERTED + ); + ProviderAuthenticationResult result = result( + new SubjectCandidate("oidc_sub", "CaseSensitiveSubject"), + List.of(), + Map.of( + "preferred_username", values("alice", ProviderAttributeTrust.ASSERTED), + "email", values("alice@example.com", ProviderAttributeTrust.VERIFIED) + ), + "oidc" + ); + + IdentityAssertion assertion = factory.create(descriptor, result); + + assertThat(assertion.profile().email()).contains( + new EmailClaim("alice@example.com", EmailAssurance.PROVIDER_ASSERTED)); + assertThat(assertion.primarySubject().value()).isEqualTo("CaseSensitiveSubject"); + } + + @Test + void rejectsProtocolClaimThatDoesNotMatchTrustedDescriptor() { + ProviderAuthenticationResult result = result( + new SubjectCandidate("github_user_id", "123456"), + List.of(), + Map.of("login", values("alice", ProviderAttributeTrust.ASSERTED)), + "oidc" + ); + + assertThatThrownBy(() -> factory.create(githubDescriptor(), result)) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.INVALID_IDENTITY_ASSERTION); + } + + @Test + void rejectsSubjectTypeOutsideTrustedDescriptorAllowlist() { + ProviderAuthenticationResult result = result( + new SubjectCandidate("email", "alice@example.com"), + List.of(), + Map.of("login", values("alice", ProviderAttributeTrust.ASSERTED)), + "oauth2-github" + ); + + assertThatThrownBy(() -> factory.create(githubDescriptor(), result)) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.INVALID_IDENTITY_ASSERTION); + } + + @Test + void rejectsNonNumericGithubSubject() { + ProviderAuthenticationResult result = result( + new SubjectCandidate("github_user_id", "alice"), + List.of(), + Map.of("login", values("alice", ProviderAttributeTrust.ASSERTED)), + "oauth2-github" + ); + + assertThatThrownBy(() -> factory.create(githubDescriptor(), result)) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.INVALID_IDENTITY_ASSERTION); + } + + @Test + void rejectsAliasesUntilBindingV2IsAvailable() { + ProviderAuthenticationResult result = result( + new SubjectCandidate("github_user_id", "123456"), + List.of(new SubjectCandidate("github_user_id", "654321")), + Map.of("login", values("alice", ProviderAttributeTrust.ASSERTED)), + "oauth2-github" + ); + + assertThatThrownBy(() -> factory.create(githubDescriptor(), result)) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.INVALID_IDENTITY_ASSERTION); + } + + @Test + void rejectsOversizedPrimarySubject() { + ProviderAuthenticationResult result = result( + new SubjectCandidate("github_user_id", "1".repeat(257)), + List.of(), + Map.of("login", values("alice", ProviderAttributeTrust.ASSERTED)), + "oauth2-github" + ); + + assertThatThrownBy(() -> factory.create(githubDescriptor(), result)) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.INVALID_IDENTITY_ASSERTION); + } + + @Test + void providerResultDefensivelyCopiesNestedCollections() { + List loginValues = new ArrayList<>( + values("alice", ProviderAttributeTrust.ASSERTED)); + Map> attributes = new HashMap<>(); + attributes.put("login", loginValues); + Set methods = new java.util.HashSet<>(Set.of("oauth2_authorization_code")); + + ProviderAuthenticationResult result = new ProviderAuthenticationResult( + new SubjectCandidate("github_user_id", "123456"), + List.of(), + attributes, + new ProtocolAuthenticationEvidence("oauth2-github", AUTHENTICATED_AT, methods) + ); + loginValues.add(new ProviderAttributeValue("mallory", ProviderAttributeTrust.ASSERTED)); + attributes.put("email", values("mallory@example.com", ProviderAttributeTrust.VERIFIED)); + methods.add("password"); + + assertThat(result.attributes()).containsOnlyKeys("login"); + assertThat(result.attributes().get("login")).hasSize(1); + assertThat(result.evidence().authenticationMethods()) + .containsExactly("oauth2_authorization_code"); + assertThatThrownBy(() -> result.attributes().get("login") + .add(new ProviderAttributeValue("blocked", ProviderAttributeTrust.ASSERTED))) + .isInstanceOf(UnsupportedOperationException.class); + } + + private static ProviderDescriptor githubDescriptor() { + return new ProviderDescriptor( + "github", + "oauth2-github", + "https://github.com", + "GitHub", + "github_user_id", + Set.of("github_user_id"), + SubjectCanonicalizer.DECIMAL, + List.of("login"), + List.of("email"), + List.of("avatar_url"), + EmailAssurance.VERIFIED + ); + } + + private static ProviderAuthenticationResult result( + SubjectCandidate primary, + List alternates, + Map> attributes, + String protocol) { + return new ProviderAuthenticationResult( + primary, + alternates, + attributes, + new ProtocolAuthenticationEvidence( + protocol, + AUTHENTICATED_AT, + Set.of("oauth2_authorization_code")) + ); + } + + private static List values( + String value, + ProviderAttributeTrust trust) { + return List.of(new ProviderAttributeValue(value, trust)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java deleted file mode 100644 index 544eb0ab..00000000 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java +++ /dev/null @@ -1,346 +0,0 @@ -package com.iflytek.skillhub.auth.identity; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.iflytek.skillhub.auth.entity.IdentityBinding; -import com.iflytek.skillhub.auth.entity.Role; -import com.iflytek.skillhub.auth.entity.UserRoleBinding; -import com.iflytek.skillhub.auth.oauth.AccountDisabledException; -import com.iflytek.skillhub.auth.oauth.AccountMergedException; -import com.iflytek.skillhub.auth.oauth.OAuthClaims; -import com.iflytek.skillhub.auth.oauth.AccountPendingException; -import com.iflytek.skillhub.auth.oauth.SystemAccountLoginException; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; -import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; -import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; -import com.iflytek.skillhub.domain.user.UserAccount; -import com.iflytek.skillhub.domain.user.UserAccountRepository; -import com.iflytek.skillhub.domain.user.UserStatus; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class IdentityBindingServiceTest { - - @Mock - private IdentityBindingRepository bindingRepo; - - @Mock - private UserAccountRepository userRepo; - - @Mock - private UserRoleBindingRepository roleBindingRepo; - - @Mock - private GlobalNamespaceMembershipService globalNamespaceMembershipService; - - private IdentityBindingService service; - - @BeforeEach - void setUp() { - service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, globalNamespaceMembershipService); - } - - @Test - void bindOrCreate_assignsGlobalMembershipForActiveNewUsers() { - OAuthClaims claims = new OAuthClaims( - "github", - "gh_1", - "alice@example.com", - true, - "alice", - Map.of("avatar_url", "https://example.test/a.png") - ); - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); - when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); - - PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.ACTIVE); - - ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); - verify(userRepo).save(userCaptor.capture()); - verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); - verify(bindingRepo).save(any(IdentityBinding.class)); - assertThat(principal.displayName()).isEqualTo("alice"); - assertThat(principal.oauthProvider()).isEqualTo("github"); - } - - @Test - void bindOrCreate_doesNotAssignGlobalMembershipForPendingUsers() { - OAuthClaims claims = new OAuthClaims( - "github", - "gh_1", - "alice@example.com", - true, - "alice", - Map.of() - ); - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); - when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.PENDING)) - .isInstanceOf(AccountPendingException.class); - - verify(globalNamespaceMembershipService, never()).ensureMember(any()); - } - - @Test - void bindOrCreate_defaultsToUserRoleWhenNoBindingsExist() { - OAuthClaims claims = new OAuthClaims( - "github", - "gh_1", - "alice@example.com", - true, - "alice", - Map.of() - ); - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); - when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); - - PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.ACTIVE); - - assertThat(principal.platformRoles()).containsExactly("USER"); - } - - @Test - void bindOrCreate_existingDisabledUser_throwsAccountDisabled() { - OAuthClaims claims = new OAuthClaims( - "github", - "gh_1", - "alice@example.com", - true, - "alice", - Map.of() - ); - IdentityBinding binding = new IdentityBinding("usr_1", "github", "gh_1", "alice"); - UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); - user.setStatus(UserStatus.DISABLED); - - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.of(binding)); - when(userRepo.findById("usr_1")).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.ACTIVE)) - .isInstanceOf(AccountDisabledException.class); - } - - @Test - void bindOrCreate_existingMergedUser_throwsBeforeProfileUpdate() { - OAuthClaims claims = new OAuthClaims( - "github", "gh_1", "attacker@example.com", true, "attacker", Map.of() - ); - IdentityBinding binding = new IdentityBinding("usr_1", "github", "gh_1", "alice"); - UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); - user.setStatus(UserStatus.MERGED); - - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.of(binding)); - when(userRepo.findById("usr_1")).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.ACTIVE)) - .isInstanceOf(AccountMergedException.class); - - assertThat(user.getDisplayName()).isEqualTo("alice"); - assertThat(user.getEmail()).isEqualTo("alice@example.com"); - verify(userRepo, never()).save(any(UserAccount.class)); - } - - @Test - void bindOrCreate_existingSystemAccount_throwsBeforeProfileUpdate() { - OAuthClaims claims = new OAuthClaims( - "github", "gh_1", "attacker@example.com", true, "attacker", Map.of() - ); - IdentityBinding binding = new IdentityBinding("system_1", "github", "gh_1", "system"); - UserAccount user = UserAccount.systemAccount("system_1", "system", null, null); - - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.of(binding)); - when(userRepo.findById("system_1")).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.ACTIVE)) - .isInstanceOf(SystemAccountLoginException.class); - - assertThat(user.getDisplayName()).isEqualTo("system"); - assertThat(user.getEmail()).isNull(); - verify(userRepo, never()).save(any(UserAccount.class)); - } - - @Test - void bindOrCreate_unverifiedEmailDoesNotPopulateNewAccount() { - OAuthClaims claims = new OAuthClaims( - "github", "gh_1", "unverified@example.com", false, "alice", Map.of() - ); - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); - when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); - - service.bindOrCreate(claims, UserStatus.ACTIVE); - - ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); - verify(userRepo).save(userCaptor.capture()); - assertThat(userCaptor.getValue().getEmail()).isNull(); - } - - @Test - void bindOrCreate_unverifiedEmailDoesNotOverwriteExistingEmail() { - OAuthClaims claims = new OAuthClaims( - "github", "gh_1", "unverified@example.com", false, "alice", Map.of() - ); - IdentityBinding binding = new IdentityBinding("usr_1", "github", "gh_1", "alice"); - UserAccount user = new UserAccount("usr_1", "alice", "verified@example.com", null); - - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.of(binding)); - when(userRepo.findById("usr_1")).thenReturn(Optional.of(user)); - when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(roleBindingRepo.findByUserId("usr_1")).thenReturn(List.of()); - - service.bindOrCreate(claims, UserStatus.ACTIVE); - - assertThat(user.getEmail()).isEqualTo("verified@example.com"); - } - - @Test - void bindOrCreate_existingApprovedUserIgnoresPendingInitialStatus() { - OAuthClaims claims = new OAuthClaims( - "github", - "gh_1", - "alice@example.com", - true, - "alice", - Map.of() - ); - IdentityBinding binding = new IdentityBinding("usr_1", "github", "gh_1", "alice"); - UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); - user.setStatus(UserStatus.ACTIVE); - - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.of(binding)); - when(userRepo.findById("usr_1")).thenReturn(Optional.of(user)); - when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(roleBindingRepo.findByUserId("usr_1")).thenReturn(List.of()); - - PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.PENDING); - - assertThat(principal.userId()).isEqualTo("usr_1"); - assertThat(principal.platformRoles()).containsExactly("USER"); - verify(globalNamespaceMembershipService, never()).ensureMember(any()); - } - - @Test - void bindOrCreate_returnsExplicitPlatformRolesWhenBindingsExist() { - OAuthClaims claims = new OAuthClaims( - "github", - "gh_1", - "alice@example.com", - true, - "alice", - Map.of() - ); - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); - when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - Role role = new Role(); - ReflectionTestUtils.setField(role, "code", "AUDITOR"); - when(roleBindingRepo.findByUserId(any())).thenReturn(List.of(new UserRoleBinding("usr_1", role))); - - PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.ACTIVE); - - assertThat(principal.platformRoles()).containsExactly("AUDITOR"); - } - - @Test - void createPendingUserIfAbsent_existingDisabledBinding_throwsAccountDisabled() { - OAuthClaims claims = new OAuthClaims( - "github", - "gh_1", - "alice@example.com", - true, - "alice", - Map.of() - ); - IdentityBinding binding = new IdentityBinding("usr_1", "github", "gh_1", "alice"); - UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); - user.setStatus(UserStatus.DISABLED); - - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.of(binding)); - when(userRepo.findById("usr_1")).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> service.createPendingUserIfAbsent(claims)) - .isInstanceOf(AccountDisabledException.class); - } - - @Test - void createPendingUserIfAbsent_existingPendingBinding_throwsAccountPending() { - OAuthClaims claims = new OAuthClaims( - "github", "gh_1", "alice@example.com", true, "alice", Map.of() - ); - IdentityBinding binding = new IdentityBinding("usr_1", "github", "gh_1", "alice"); - UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); - user.setStatus(UserStatus.PENDING); - - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.of(binding)); - when(userRepo.findById("usr_1")).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> service.createPendingUserIfAbsent(claims)) - .isInstanceOf(AccountPendingException.class); - verify(userRepo, never()).save(any(UserAccount.class)); - verify(bindingRepo, never()).save(any(IdentityBinding.class)); - } - - @Test - void createPendingUserIfAbsent_existingMergedBinding_throwsAccountMerged() { - OAuthClaims claims = new OAuthClaims( - "github", "gh_1", "alice@example.com", true, "alice", Map.of() - ); - IdentityBinding binding = new IdentityBinding("usr_1", "github", "gh_1", "alice"); - UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); - user.setStatus(UserStatus.MERGED); - - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.of(binding)); - when(userRepo.findById("usr_1")).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> service.createPendingUserIfAbsent(claims)) - .isInstanceOf(AccountMergedException.class); - } - - @Test - void createPendingUserIfAbsent_existingSystemBinding_throwsSystemAccountLogin() { - OAuthClaims claims = new OAuthClaims( - "github", "gh_1", "alice@example.com", true, "alice", Map.of() - ); - IdentityBinding binding = new IdentityBinding("system_1", "github", "gh_1", "system"); - UserAccount user = UserAccount.systemAccount("system_1", "system", null, null); - - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.of(binding)); - when(userRepo.findById("system_1")).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> service.createPendingUserIfAbsent(claims)) - .isInstanceOf(SystemAccountLoginException.class); - } - - @Test - void createPendingUserIfAbsent_unverifiedEmailDoesNotPopulateAccount() { - OAuthClaims claims = new OAuthClaims( - "github", "gh_1", "unverified@example.com", false, "alice", Map.of() - ); - when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); - when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - service.createPendingUserIfAbsent(claims); - - ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); - verify(userRepo).save(userCaptor.capture()); - assertThat(userCaptor.getValue().getEmail()).isNull(); - } -} 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 new file mode 100644 index 00000000..dd3f5c6b --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java @@ -0,0 +1,279 @@ +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.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class IdentityResolutionTransactionTest { + + private IdentityBindingRepository bindingRepository; + private UserAccountRepository userRepository; + private GlobalNamespaceMembershipService membershipService; + private AccountLoginGuard accountLoginGuard; + private PlatformPrincipalFactory principalFactory; + private IdentityResolutionTransaction transaction; + + @BeforeEach + void setUp() { + bindingRepository = mock(IdentityBindingRepository.class); + userRepository = mock(UserAccountRepository.class); + membershipService = mock(GlobalNamespaceMembershipService.class); + accountLoginGuard = new AccountLoginGuard(); + principalFactory = mock(PlatformPrincipalFactory.class); + transaction = new IdentityResolutionTransaction( + bindingRepository, + userRepository, + membershipService, + accountLoginGuard, + principalFactory); + } + + @Test + void createsActiveAccountBindingMembershipAndPrincipal() { + IdentityAssertion assertion = assertion( + EmailAssurance.VERIFIED, + "alice@example.com"); + when(bindingRepository.findByProviderCodeAndSubject( + "github", + "123456")).thenReturn(Optional.empty()); + when(userRepository.save(any(UserAccount.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + PlatformPrincipal principal = principal("generated"); + when(principalFactory.create(any(UserAccount.class), org.mockito.ArgumentMatchers.eq("github"))) + .thenReturn(principal); + + IdentityLoginOutcome outcome = + transaction.resolve(assertion, UserStatus.ACTIVE); + + assertThat(outcome) + .isEqualTo(new IdentityLoginOutcome.Authenticated( + principal, + true, + true)); + ArgumentCaptor userCaptor = + ArgumentCaptor.forClass(UserAccount.class); + verify(userRepository).save(userCaptor.capture()); + UserAccount created = userCaptor.getValue(); + assertThat(created.getId()).startsWith("usr_"); + assertThat(created.getDisplayName()).isEqualTo("alice"); + assertThat(created.getEmail()).isEqualTo("alice@example.com"); + assertThat(created.getAvatarUrl()) + .isEqualTo("https://avatars.example/alice.png"); + verify(membershipService).ensureMember(created.getId()); + ArgumentCaptor bindingCaptor = + ArgumentCaptor.forClass(IdentityBinding.class); + verify(bindingRepository).save(bindingCaptor.capture()); + assertThat(bindingCaptor.getValue().getProviderCode()) + .isEqualTo("github"); + assertThat(bindingCaptor.getValue().getSubject()) + .isEqualTo("123456"); + } + + @Test + void createsPendingAccountWithoutMembershipOrSessionPrincipal() { + IdentityAssertion assertion = assertion( + EmailAssurance.VERIFIED, + "alice@example.com"); + when(bindingRepository.findByProviderCodeAndSubject( + "github", + "123456")).thenReturn(Optional.empty()); + when(userRepository.save(any(UserAccount.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + IdentityLoginOutcome outcome = + transaction.resolve(assertion, UserStatus.PENDING); + + assertThat(outcome).isEqualTo( + new IdentityLoginOutcome.PendingApproval("ACCOUNT_PENDING")); + verify(membershipService, never()).ensureMember(any()); + verify(principalFactory, never()).create(any(), any()); + verify(bindingRepository).save(any(IdentityBinding.class)); + } + + @Test + void existingApprovedAccountIgnoresPendingProvisioningDefault() { + IdentityBinding binding = + new IdentityBinding("usr_1", "github", "123456", "alice"); + UserAccount user = new UserAccount( + "usr_1", + "old", + "old@example.com", + null); + when(bindingRepository.findByProviderCodeAndSubject( + "github", + "123456")).thenReturn(Optional.of(binding)); + when(userRepository.findById("usr_1")).thenReturn(Optional.of(user)); + when(userRepository.save(user)).thenReturn(user); + PlatformPrincipal principal = principal("usr_1"); + when(principalFactory.create(user, "github")).thenReturn(principal); + + IdentityLoginOutcome outcome = + transaction.resolve( + assertion(EmailAssurance.VERIFIED, "alice@example.com"), + UserStatus.PENDING); + + assertThat(outcome).isEqualTo( + new IdentityLoginOutcome.Authenticated( + principal, + false, + false)); + assertThat(user.getDisplayName()).isEqualTo("alice"); + assertThat(user.getEmail()).isEqualTo("alice@example.com"); + verify(membershipService, never()).ensureMember(any()); + verify(bindingRepository, never()).save(binding); + } + + @Test + void existingPendingAccountReturnsPendingBeforeProfileMutation() { + IdentityBinding binding = + new IdentityBinding("usr_1", "github", "123456", "alice"); + UserAccount user = new UserAccount( + "usr_1", + "original", + "original@example.com", + null); + user.setStatus(UserStatus.PENDING); + when(bindingRepository.findByProviderCodeAndSubject( + "github", + "123456")).thenReturn(Optional.of(binding)); + when(userRepository.findById("usr_1")).thenReturn(Optional.of(user)); + + IdentityLoginOutcome outcome = + transaction.resolve( + assertion(EmailAssurance.VERIFIED, "changed@example.com"), + UserStatus.ACTIVE); + + assertThat(outcome).isEqualTo( + new IdentityLoginOutcome.PendingApproval("ACCOUNT_PENDING")); + assertThat(user.getDisplayName()).isEqualTo("original"); + assertThat(user.getEmail()).isEqualTo("original@example.com"); + verify(userRepository, never()).save(user); + verify(principalFactory, never()).create(any(), any()); + } + + @Test + void blockedExistingAccountFailsBeforeProfileMutation() { + assertBlocked(UserStatus.DISABLED, false, IdentityFailureCode.ACCOUNT_DISABLED); + assertBlocked(UserStatus.MERGED, false, IdentityFailureCode.ACCOUNT_MERGED); + assertBlocked(UserStatus.ACTIVE, true, IdentityFailureCode.SYSTEM_ACCOUNT_FORBIDDEN); + } + + @Test + void unverifiedEmailNeverPopulatesOrOverwritesTrustedProfile() { + IdentityAssertion assertion = assertion( + EmailAssurance.UNVERIFIED, + "unverified@example.com"); + when(bindingRepository.findByProviderCodeAndSubject( + "github", + "123456")).thenReturn(Optional.empty()); + when(userRepository.save(any(UserAccount.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(principalFactory.create(any(), any())).thenReturn(principal("new")); + + transaction.resolve(assertion, UserStatus.ACTIVE); + + ArgumentCaptor userCaptor = + ArgumentCaptor.forClass(UserAccount.class); + verify(userRepository).save(userCaptor.capture()); + assertThat(userCaptor.getValue().getEmail()).isNull(); + } + + private void assertBlocked( + UserStatus status, + boolean system, + IdentityFailureCode expectedCode) { + IdentityBindingRepository localBindingRepository = + mock(IdentityBindingRepository.class); + UserAccountRepository localUserRepository = + mock(UserAccountRepository.class); + IdentityResolutionTransaction localTransaction = + new IdentityResolutionTransaction( + localBindingRepository, + localUserRepository, + membershipService, + accountLoginGuard, + principalFactory); + IdentityBinding binding = + new IdentityBinding("usr_blocked", "github", "123456", "old"); + UserAccount user = system + ? UserAccount.systemAccount( + "usr_blocked", + "original", + "original@example.com", + null) + : new UserAccount( + "usr_blocked", + "original", + "original@example.com", + null); + user.setStatus(status); + when(localBindingRepository.findByProviderCodeAndSubject( + "github", + "123456")).thenReturn(Optional.of(binding)); + when(localUserRepository.findById("usr_blocked")) + .thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> localTransaction.resolve( + assertion(EmailAssurance.VERIFIED, "changed@example.com"), + UserStatus.ACTIVE)) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(expectedCode); + + assertThat(user.getDisplayName()).isEqualTo("original"); + assertThat(user.getEmail()).isEqualTo("original@example.com"); + verify(localUserRepository, never()).save(user); + } + + private static IdentityAssertion assertion( + EmailAssurance assurance, + String email) { + return new IdentityAssertion( + new ProviderReference( + "github", + "oauth2-github", + "https://github.com"), + new ExternalSubject("github_user_id", "123456"), + Set.of(), + new ExternalProfile( + "alice", + Optional.of(new EmailClaim(email, assurance)), + Optional.of(URI.create( + "https://avatars.example/alice.png"))), + Map.of(), + new AuthenticationEvidence( + "oauth2-github", + Instant.parse("2026-07-30T08:00:00Z"), + Set.of("oauth2_authorization_code"))); + } + + private static PlatformPrincipal principal(String userId) { + return new PlatformPrincipal( + userId, + "alice", + "alice@example.com", + null, + "github", + Set.of("USER")); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/PlatformPrincipalFactoryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/PlatformPrincipalFactoryTest.java new file mode 100644 index 00000000..6aa0d6d9 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/PlatformPrincipalFactoryTest.java @@ -0,0 +1,56 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import java.util.List; +import org.junit.jupiter.api.Test; + +class PlatformPrincipalFactoryTest { + + @Test + void defaultsToUserRoleWhenNoExplicitBindingExists() { + UserRoleBindingRepository repository = + mock(UserRoleBindingRepository.class); + when(repository.findByUserId("usr_1")).thenReturn(List.of()); + PlatformPrincipalFactory factory = + new PlatformPrincipalFactory(repository); + + var principal = factory.create(user(), "github"); + + assertThat(principal.userId()).isEqualTo("usr_1"); + assertThat(principal.displayName()).isEqualTo("alice"); + assertThat(principal.oauthProvider()).isEqualTo("github"); + assertThat(principal.platformRoles()).containsExactly("USER"); + } + + @Test + void usesExplicitPlatformRolesWhenPresent() { + UserRoleBindingRepository repository = + mock(UserRoleBindingRepository.class); + Role role = mock(Role.class); + when(role.getCode()).thenReturn("AUDITOR"); + when(repository.findByUserId("usr_1")) + .thenReturn(List.of(new UserRoleBinding("usr_1", role))); + PlatformPrincipalFactory factory = + new PlatformPrincipalFactory(repository); + + var principal = factory.create(user(), "local"); + + assertThat(principal.oauthProvider()).isEqualTo("local"); + assertThat(principal.platformRoles()).containsExactly("AUDITOR"); + } + + private static UserAccount user() { + return new UserAccount( + "usr_1", + "alice", + "alice@example.com", + "https://avatars.example/alice.png"); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityFingerprintTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityFingerprintTest.java new file mode 100644 index 00000000..1a804506 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityFingerprintTest.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class ProviderAuthorityFingerprintTest { + + @Test + void matchesFrozenOidcVector() { + assertThat(ProviderAuthorityFingerprint.sha256( + "oidc", + "https://id.example.com")) + .isEqualTo( + "4d12ea0e7a413a716adf2acf2434f2a0642e74abcd3e3273bad170127c53fd00"); + } + + @Test + void matchesFrozenGithubVector() { + assertThat(ProviderAuthorityFingerprint.sha256( + "oauth2-github", + "https://github.com")) + .isEqualTo( + "b2a93d58465e3de9e8b6cd127ba18425ae0f80c49c85f18f76086832923ca619"); + } + + @Test + void usesExactAuthorityBytesWithoutImplicitNormalization() { + assertThat(ProviderAuthorityFingerprint.sha256( + "oidc", + "https://id.example.com/")) + .isNotEqualTo(ProviderAuthorityFingerprint.sha256( + "oidc", + "https://id.example.com")); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityLockServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityLockServiceTest.java new file mode 100644 index 00000000..7048c02a --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityLockServiceTest.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +class ProviderAuthorityLockServiceTest { + + @Test + void requirePinnedAuthorityFailsClosedOnStickyMismatch() { + ProviderDescriptor descriptor = descriptor(); + String fingerprint = ProviderAuthorityFingerprint.sha256( + descriptor.protocol(), + descriptor.canonicalAuthority()); + ProviderAuthorityStateTransaction transaction = + mock(ProviderAuthorityStateTransaction.class); + when(transaction.pin(descriptor, fingerprint)).thenReturn( + new AuthorityLockEvaluation( + IdentityProviderStatus.AUTHORITY_MISMATCH, + fingerprint)); + ProviderAuthorityLockService service = + new ProviderAuthorityLockService(transaction); + + assertThatThrownBy(() -> service.requirePinnedAuthority(descriptor)) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH); + } + + private static ProviderDescriptor descriptor() { + return new ProviderDescriptor( + "github", + "oauth2-github", + "https://github.com", + "GitHub", + "github_user_id", + java.util.Set.of("github_user_id"), + SubjectCanonicalizer.DECIMAL, + java.util.List.of("login"), + java.util.List.of("email"), + java.util.List.of("avatar_url"), + EmailAssurance.VERIFIED); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityStateTransactionTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityStateTransactionTest.java new file mode 100644 index 00000000..8a180a0a --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProviderAuthorityStateTransactionTest.java @@ -0,0 +1,425 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +class ProviderAuthorityStateTransactionTest { + + private static final ProviderDescriptor GITHUB = githubDescriptor(); + private static final String FINGERPRINT = + "b2a93d58465e3de9e8b6cd127ba18425ae0f80c49c85f18f76086832923ca619"; + private static final Instant NOW = Instant.parse("2026-07-30T08:30:00Z"); + + private IdentityProviderStateRepository stateRepository; + private IdentityBindingRepository bindingRepository; + private AuditLogService auditLogService; + private ProviderAuthorityStateTransaction transaction; + + @BeforeEach + void setUp() { + stateRepository = mock(IdentityProviderStateRepository.class); + bindingRepository = mock(IdentityBindingRepository.class); + auditLogService = mock(AuditLogService.class); + transaction = new ProviderAuthorityStateTransaction( + stateRepository, + bindingRepository, + auditLogService); + } + + @Test + void insertsReadyPinForProviderWithoutLegacyBindingsAndRereadsIt() { + IdentityProviderState ready = IdentityProviderState.ready( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT, + NOW); + when(stateRepository.findById("github")) + .thenReturn(Optional.empty(), Optional.of(ready)); + when(bindingRepository.existsByProviderCode("github")).thenReturn(false); + when(stateRepository.insertReady( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT)).thenReturn(1); + + AuthorityLockEvaluation evaluation = transaction.pin(GITHUB, FINGERPRINT); + + assertThat(evaluation.ready()).isTrue(); + InOrder order = inOrder(stateRepository); + order.verify(stateRepository).findById("github"); + order.verify(stateRepository).insertReady( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT); + order.verify(stateRepository).findById("github"); + } + + @Test + void pinsLegacyProviderWithCompareAndSetBeforeReread() { + IdentityProviderState legacy = IdentityProviderState.legacyUnpinned( + "github", + "oauth2-github", + NOW); + IdentityProviderState ready = IdentityProviderState.ready( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT, + NOW); + when(stateRepository.findById("github")) + .thenReturn(Optional.empty(), Optional.of(legacy), Optional.of(ready)); + when(bindingRepository.existsByProviderCode("github")).thenReturn(true); + when(stateRepository.insertLegacyUnpinned( + "github", + "oauth2-github")).thenReturn(1); + when(stateRepository.pinLegacy( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT)).thenReturn(1); + + AuthorityLockEvaluation evaluation = transaction.pin(GITHUB, FINGERPRINT); + + assertThat(evaluation.ready()).isTrue(); + InOrder order = inOrder(stateRepository); + order.verify(stateRepository).findById("github"); + order.verify(stateRepository) + .insertLegacyUnpinned("github", "oauth2-github"); + order.verify(stateRepository).findById("github"); + order.verify(stateRepository).pinLegacy( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT); + order.verify(stateRepository).findById("github"); + } + + @Test + void concurrentSameAuthorityConvergesOnPersistedReadyState() { + IdentityProviderState ready = IdentityProviderState.ready( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT, + NOW); + when(stateRepository.findById("github")) + .thenReturn(Optional.empty(), Optional.of(ready)); + when(bindingRepository.existsByProviderCode("github")).thenReturn(false); + when(stateRepository.insertReady( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT)).thenReturn(0); + + AuthorityLockEvaluation evaluation = transaction.pin(GITHUB, FINGERPRINT); + + assertThat(evaluation.ready()).isTrue(); + verify(stateRepository, never()).markAuthorityMismatch( + "github", + "oauth2-github", + FINGERPRINT); + } + + @Test + void changedAuthorityIsMarkedMismatchWithoutOverwritingPinnedValues() { + IdentityProviderState pinned = IdentityProviderState.ready( + "github", + "oauth2-github", + "https://github.example", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + NOW); + IdentityProviderState mismatch = IdentityProviderState.authorityMismatch( + "github", + "oauth2-github", + "https://github.example", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + NOW); + when(stateRepository.findById("github")) + .thenReturn(Optional.of(pinned), Optional.of(mismatch)); + when(stateRepository.markAuthorityMismatch( + "github", + "oauth2-github", + FINGERPRINT)).thenReturn(1); + + AuthorityLockEvaluation evaluation = transaction.pin(GITHUB, FINGERPRINT); + + assertThat(evaluation.ready()).isFalse(); + assertThat(evaluation.state()) + .isEqualTo(IdentityProviderStatus.AUTHORITY_MISMATCH); + assertThat(evaluation.persistedFingerprint()) + .isEqualTo(pinned.getAuthorityFingerprint()); + verify(stateRepository).markAuthorityMismatch( + "github", + "oauth2-github", + FINGERPRINT); + verify(stateRepository, never()).pinLegacy( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT); + } + + @Test + void rereadsPersistedStateWhenReadyTouchLosesAuthorityRace() { + IdentityProviderState cachedReady = IdentityProviderState.ready( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT, + NOW); + IdentityProviderState mismatch = IdentityProviderState.authorityMismatch( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT, + NOW); + when(stateRepository.findById("github")) + .thenReturn(Optional.of(cachedReady), Optional.of(mismatch)); + when(stateRepository.touchReady( + "github", + "oauth2-github", + FINGERPRINT)) + .thenReturn(0); + + AuthorityLockEvaluation evaluation = + transaction.pin(GITHUB, FINGERPRINT); + + assertThat(evaluation.ready()).isFalse(); + assertThat(evaluation.state()) + .isEqualTo(IdentityProviderStatus.AUTHORITY_MISMATCH); + verify(stateRepository).touchReady( + "github", + "oauth2-github", + FINGERPRINT); + verify(stateRepository, never()).recoverSameAuthority( + "github", + "oauth2-github", + FINGERPRINT); + } + + @Test + void mismatchIsStickyEvenAfterConfigurationReturnsToPinnedFingerprint() { + IdentityProviderState mismatch = IdentityProviderState.authorityMismatch( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT, + NOW); + when(stateRepository.findById("github")) + .thenReturn(Optional.of(mismatch)); + + AuthorityLockEvaluation evaluation = transaction.pin(GITHUB, FINGERPRINT); + + assertThat(evaluation.ready()).isFalse(); + assertThat(evaluation.state()) + .isEqualTo(IdentityProviderStatus.AUTHORITY_MISMATCH); + verify(stateRepository, never()).recoverSameAuthority( + "github", + "oauth2-github", + FINGERPRINT); + verify(stateRepository, never()).markAuthorityMismatch( + "github", + "oauth2-github", + FINGERPRINT); + } + + @Test + void changedProtocolIsMismatchEvenIfStoredFingerprintWasTamperedToMatch() { + IdentityProviderState pinned = IdentityProviderState.ready( + "github", + "oidc", + "https://github.com", + FINGERPRINT, + NOW); + IdentityProviderState mismatch = IdentityProviderState.authorityMismatch( + "github", + "oidc", + "https://github.com", + FINGERPRINT, + NOW); + when(stateRepository.findById("github")) + .thenReturn(Optional.of(pinned), Optional.of(mismatch)); + when(stateRepository.markAuthorityMismatch( + "github", + "oauth2-github", + FINGERPRINT)).thenReturn(1); + + AuthorityLockEvaluation evaluation = transaction.pin(GITHUB, FINGERPRINT); + + assertThat(evaluation.state()) + .isEqualTo(IdentityProviderStatus.AUTHORITY_MISMATCH); + verify(stateRepository).markAuthorityMismatch( + "github", + "oauth2-github", + FINGERPRINT); + } + + @Test + void explicitRecoveryOnlySucceedsForThePersistedFingerprint() { + IdentityProviderState recovered = IdentityProviderState.ready( + "github", + "oauth2-github", + "https://github.com", + FINGERPRINT, + NOW); + when(stateRepository.recoverSameAuthority( + "github", + "oauth2-github", + FINGERPRINT)).thenReturn(1); + when(stateRepository.findById("github")) + .thenReturn(Optional.of(recovered)); + + IdentityProviderAuthorityRecoveryContext context = + recoveryContext(); + SameAuthorityRecoveryEvaluation recovery = + transaction.recoverSameAuthority( + GITHUB, + FINGERPRINT, + context); + + assertThat(recovery.recovered()).isTrue(); + assertThat(recovery.authority().ready()).isTrue(); + verify(stateRepository).recoverSameAuthority( + "github", + "oauth2-github", + FINGERPRINT); + verify(auditLogService).record( + "admin", + "PROVIDER_AUTHORITY_RECOVERED", + "IDENTITY_PROVIDER", + null, + "req-123", + "203.0.113.9", + "SkillHub Browser", + "{\"providerCode\":\"github\",\"protocol\":\"oauth2-github\",\"authorityFingerprint\":\"" + + FINGERPRINT + + "\"}"); + } + + @Test + void failedRecoveryDoesNotWriteMisleadingAudit() { + IdentityProviderState mismatch = + IdentityProviderState.authorityMismatch( + "github", + "oauth2-github", + "https://github.example", + "a".repeat(64), + NOW); + when(stateRepository.recoverSameAuthority( + "github", + "oauth2-github", + FINGERPRINT)).thenReturn(0); + when(stateRepository.findById("github")) + .thenReturn(Optional.of(mismatch)); + + SameAuthorityRecoveryEvaluation recovery = + transaction.recoverSameAuthority( + GITHUB, + FINGERPRINT, + recoveryContext()); + + assertThat(recovery.recovered()).isFalse(); + assertThat(recovery.authority().state()) + .isEqualTo(IdentityProviderStatus.AUTHORITY_MISMATCH); + verify(auditLogService, never()).record( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void readyStateWithDifferentFingerprintIsPersistedAsMismatch() { + String differentFingerprint = "a".repeat(64); + IdentityProviderState staleReady = IdentityProviderState.ready( + "github", + "oauth2-github", + "https://github.example", + differentFingerprint, + NOW); + IdentityProviderState mismatch = + IdentityProviderState.authorityMismatch( + "github", + "oauth2-github", + "https://github.example", + differentFingerprint, + NOW); + when(stateRepository.recoverSameAuthority( + "github", + "oauth2-github", + FINGERPRINT)).thenReturn(0); + when(stateRepository.findById("github")) + .thenReturn( + Optional.of(staleReady), + Optional.of(mismatch)); + when(stateRepository.markAuthorityMismatch( + "github", + "oauth2-github", + FINGERPRINT)).thenReturn(1); + + SameAuthorityRecoveryEvaluation recovery = + transaction.recoverSameAuthority( + GITHUB, + FINGERPRINT, + recoveryContext()); + + assertThat(recovery.recovered()).isFalse(); + assertThat(recovery.authority().state()) + .isEqualTo(IdentityProviderStatus.AUTHORITY_MISMATCH); + verify(stateRepository).markAuthorityMismatch( + "github", + "oauth2-github", + FINGERPRINT); + verify(auditLogService, never()).record( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + private static ProviderDescriptor githubDescriptor() { + return new ProviderDescriptor( + "github", + "oauth2-github", + "https://github.com", + "GitHub", + "github_user_id", + java.util.Set.of("github_user_id"), + SubjectCanonicalizer.DECIMAL, + java.util.List.of("login"), + java.util.List.of("email"), + java.util.List.of("avatar_url"), + EmailAssurance.VERIFIED); + } + + private static IdentityProviderAuthorityRecoveryContext + recoveryContext() { + return new IdentityProviderAuthorityRecoveryContext( + "admin", + "req-123", + "203.0.113.9", + "SkillHub Browser"); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalogTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalogTest.java new file mode 100644 index 00000000..bf7cae67 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ReconciledIdentityProviderCatalogTest.java @@ -0,0 +1,121 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +class ReconciledIdentityProviderCatalogTest { + + private TrustedProviderDescriptorSource descriptorSource; + private ProviderAuthorityLockService authorityLockService; + private ReconciledIdentityProviderCatalog catalog; + + @BeforeEach + void setUp() { + descriptorSource = mock(TrustedProviderDescriptorSource.class); + authorityLockService = mock(ProviderAuthorityLockService.class); + catalog = new ReconciledIdentityProviderCatalog( + descriptorSource, + authorityLockService); + } + + @Test + void publishesProviderOnlyAfterPinAndPersistedStateReread() { + ProviderDescriptor github = descriptor("github", "GitHub"); + when(descriptorSource.enabledDescriptors()) + .thenReturn(List.of(github)); + when(authorityLockService.isReady(github)).thenReturn(true); + + catalog.reconcile(); + + assertThat(catalog.listReadyProviders()) + .containsExactly(new IdentityProviderLoginMethod( + "github", + "GitHub")); + InOrder order = inOrder(authorityLockService); + order.verify(authorityLockService, times(2)) + .requirePinnedAuthority(github); + order.verify(authorityLockService).isReady(github); + } + + @Test + void hidesProvidersWhosePinOrPersistedStateCheckFails() { + ProviderDescriptor github = descriptor("github", "GitHub"); + ProviderDescriptor gitlab = descriptor("gitlab", "GitLab"); + when(descriptorSource.enabledDescriptors()) + .thenReturn(List.of(github, gitlab)); + doThrow(new IdentityCoreException( + IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH)) + .when(authorityLockService) + .requirePinnedAuthority(github); + when(authorityLockService.isReady(github)).thenReturn(false); + when(authorityLockService.isReady(gitlab)).thenReturn(false); + + catalog.reconcile(); + + assertThat(catalog.listReadyProviders()).isEmpty(); + } + + @Test + void persistedStateReadFailureCannotExposePreviouslyReadyProvider() { + ProviderDescriptor github = descriptor("github", "GitHub"); + when(descriptorSource.enabledDescriptors()) + .thenReturn(List.of(github)); + when(authorityLockService.isReady(github)).thenReturn(true); + catalog.reconcile(); + assertThat(catalog.listReadyProviders()).hasSize(1); + + doThrow(new IllegalStateException("database unavailable")) + .when(authorityLockService) + .requirePinnedAuthority(github); + when(authorityLockService.isReady(github)) + .thenThrow(new IllegalStateException( + "database unavailable")); + + catalog.reconcile(); + + assertThat(catalog.listReadyProviders()).isEmpty(); + } + + @Test + void reflectsSameAuthorityRecoveryWithoutApplicationRestart() { + ProviderDescriptor github = descriptor("github", "GitHub"); + when(descriptorSource.enabledDescriptors()) + .thenReturn(List.of(github)); + when(authorityLockService.isReady(github)) + .thenReturn(false, true); + catalog.reconcile(); + + assertThat(catalog.listReadyProviders()).isEmpty(); + assertThat(catalog.listReadyProviders()) + .containsExactly(new IdentityProviderLoginMethod( + "github", + "GitHub")); + } + + private static ProviderDescriptor descriptor( + String providerCode, + String displayName) { + return new ProviderDescriptor( + providerCode, + "oidc", + "https://" + providerCode + ".example", + displayName, + "oidc_sub", + Set.of("oidc_sub"), + SubjectCanonicalizer.EXACT, + List.of("name"), + List.of("email"), + List.of("picture"), + EmailAssurance.VERIFIED); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java new file mode 100644 index 00000000..a7318f0e --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java @@ -0,0 +1,222 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; + +class StaticTrustedProviderDescriptorSourceTest { + + @Test + void resolvesConfiguredGithubFromServerOwnedRegistration() { + ClientRegistration github = github(); + StaticTrustedProviderDescriptorSource source = source( + Map.of("github", properties("client-id", "GitHub")), + Set.of("github"), + github); + + ResolvedProviderHandle handle = source.resolve(github); + ProviderDescriptor descriptor = source.require(handle); + + assertThat(handle.providerCode()).isEqualTo("github"); + assertThat(descriptor.protocol()).isEqualTo("oauth2-github"); + assertThat(descriptor.canonicalAuthority()) + .isEqualTo("https://github.com"); + assertThat(descriptor.primarySubjectType()) + .isEqualTo("github_user_id"); + } + + @Test + void rejectsReconstructedRegistrationEvenWhenVisibleFieldsMatch() { + ClientRegistration trustedGithub = github(); + StaticTrustedProviderDescriptorSource source = source( + Map.of("github", properties("client-id", "GitHub")), + Set.of("github"), + trustedGithub); + + assertThatThrownBy(() -> source.resolve(github())) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.PROVIDER_DISABLED); + } + + @Test + void derivesSelfManagedGitlabAuthorityFromValidatedEndpoints() { + ClientRegistration gitlab = gitlab( + "https://gitlab.example/corp"); + StaticTrustedProviderDescriptorSource source = source( + Map.of("gitlab", properties("client-id", "Corporate GitLab")), + Set.of("gitlab"), + gitlab); + + ProviderDescriptor descriptor = + source.require(source.resolve(gitlab)); + + assertThat(descriptor.protocol()).isEqualTo("oauth2-gitlab"); + assertThat(descriptor.canonicalAuthority()) + .isEqualTo("https://gitlab.example/corp"); + assertThat(descriptor.primarySubjectType()) + .isEqualTo("gitlab_user_id"); + } + + @Test + void preservesExactOidcIssuerAndCaseSensitiveSubjectRules() { + ClientRegistration oidc = oidc( + "corp-oidc", + "https://id.example.com/tenant"); + StaticTrustedProviderDescriptorSource source = source( + Map.of("corp-oidc", properties("client-id", "Corporate OIDC")), + Set.of(), + oidc); + + ProviderDescriptor descriptor = + source.require(source.resolve(oidc)); + + assertThat(descriptor.protocol()).isEqualTo("oidc"); + assertThat(descriptor.canonicalAuthority()) + .isEqualTo("https://id.example.com/tenant"); + assertThat(descriptor.primarySubjectType()).isEqualTo("oidc_sub"); + assertThat(descriptor.subjectCanonicalizer()) + .isEqualTo(SubjectCanonicalizer.EXACT); + } + + @Test + void hidesPlaceholderAndUnsupportedOAuthRegistrations() { + ClientRegistration github = github(); + ClientRegistration unsupported = oauth( + "unsupported", + "https://login.example.com"); + StaticTrustedProviderDescriptorSource source = source( + Map.of( + "github", properties("local-placeholder", "GitHub"), + "unsupported", + properties("real-client", "Unsupported")), + Set.of("github"), + github, + unsupported); + + assertThat(source.enabledDescriptors()).isEmpty(); + assertThatThrownBy(() -> source.resolve(github)) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.PROVIDER_DISABLED); + assertThatThrownBy(() -> source.resolve(unsupported)) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.PROVIDER_DISABLED); + } + + @Test + void rejectsRegistrationThatIsBothOidcAndBackedByOAuthExtractor() { + ClientRegistration ambiguous = oidc( + "custom", + "https://id.example.com"); + StaticTrustedProviderDescriptorSource source = source( + Map.of("custom", properties("client-id", "Ambiguous")), + Set.of("custom"), + ambiguous); + + assertThat(source.enabledDescriptors()).isEmpty(); + assertThatThrownBy(() -> source.resolve(ambiguous)) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.PROVIDER_DISABLED); + } + + private static StaticTrustedProviderDescriptorSource source( + Map registrations, + Set extractorCodes, + ClientRegistration... clientRegistrations) { + OAuth2ClientProperties properties = new OAuth2ClientProperties(); + properties.getRegistration().putAll(registrations); + return new StaticTrustedProviderDescriptorSource( + properties, + new InMemoryClientRegistrationRepository(clientRegistrations), + extractorCodes); + } + + private static OAuth2ClientProperties.Registration properties( + String clientId, + String clientName) { + OAuth2ClientProperties.Registration registration = + new OAuth2ClientProperties.Registration(); + registration.setClientId(clientId); + registration.setClientName(clientName); + return registration; + } + + private static ClientRegistration github() { + return ClientRegistration.withRegistrationId("github") + .clientId("client-id") + .clientSecret("client-secret") + .clientName("GitHub") + .authorizationGrantType( + AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri( + "https://github.com/login/oauth/authorize") + .tokenUri( + "https://github.com/login/oauth/access_token") + .userInfoUri("https://api.github.com/user") + .userNameAttributeName("id") + .build(); + } + + private static ClientRegistration gitlab(String authority) { + return ClientRegistration.withRegistrationId("gitlab") + .clientId("client-id") + .clientSecret("client-secret") + .clientName("GitLab") + .authorizationGrantType( + AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri(authority + "/oauth/authorize") + .tokenUri(authority + "/oauth/token") + .userInfoUri(authority + "/api/v4/user") + .userNameAttributeName("username") + .build(); + } + + private static ClientRegistration oidc( + String registrationId, + String issuer) { + return ClientRegistration.withRegistrationId(registrationId) + .clientId("client-id") + .clientSecret("client-secret") + .clientName("OIDC") + .authorizationGrantType( + AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .issuerUri(issuer) + .authorizationUri(issuer + "/authorize") + .tokenUri(issuer + "/token") + .jwkSetUri(issuer + "/jwks") + .userInfoUri(issuer + "/userinfo") + .userNameAttributeName("sub") + .scope("openid") + .build(); + } + + private static ClientRegistration oauth( + String registrationId, + String authority) { + return ClientRegistration.withRegistrationId(registrationId) + .clientId("client-id") + .clientSecret("client-secret") + .clientName(registrationId) + .authorizationGrantType( + AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri(authority + "/authorize") + .tokenUri(authority + "/token") + .userInfoUri(authority + "/userinfo") + .userNameAttributeName("id") + .build(); + } +} 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 b6eaf5af..36f3ccfd 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 @@ -12,6 +12,8 @@ import static org.mockito.Mockito.verify; import com.iflytek.skillhub.auth.exception.AuthFlowException; 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.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; @@ -58,11 +60,12 @@ class LocalAuthServiceTest { service = new LocalAuthService( credentialRepository, userAccountRepository, - userRoleBindingRepository, globalNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, - CLOCK + CLOCK, + new AccountLoginGuard(), + new PlatformPrincipalFactory(userRoleBindingRepository) ); } @@ -215,6 +218,25 @@ class LocalAuthServiceTest { .hasMessageContaining("error.auth.local.accountMerged"); } + @Test + void login_withSystemAccount_fails() { + LocalCredential credential = + new LocalCredential("system_1", "system", "encoded"); + UserAccount user = UserAccount.systemAccount( + "system_1", "system", null, null); + + given(credentialRepository.findByUsernameIgnoreCase("system")) + .willReturn(Optional.of(credential)); + given(userAccountRepository.findById("system_1")) + .willReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.login("system", "Abcd123!")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.local.systemAccount"); + + verify(passwordEncoder, never()).matches("Abcd123!", "encoded"); + } + @Test void login_withoutExplicitRoles_defaultsToUser() { LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserServiceTest.java new file mode 100644 index 00000000..45a003fc --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserServiceTest.java @@ -0,0 +1,59 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; + +class CustomOAuth2UserServiceTest { + + @Test + void passesCurrentHttpAuditContextIntoUnifiedLoginFlow() { + OAuthLoginFlowService loginFlowService = + mock(OAuthLoginFlowService.class); + OAuthIdentityLoginContextResolver contextResolver = + mock(OAuthIdentityLoginContextResolver.class); + OAuth2UserRequest request = mock(OAuth2UserRequest.class); + IdentityLoginContext loginContext = new IdentityLoginContext( + "req-123", + "203.0.113.9", + "SkillHub Browser"); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "Alice", + "alice@example.com", + null, + "github", + Set.of("USER")); + OAuth2User upstream = new DefaultOAuth2User( + Set.of(new SimpleGrantedAuthority("OAUTH2_USER")), + Map.of("id", "123"), + "id"); + when(contextResolver.current()).thenReturn(loginContext); + when(loginFlowService.loadLoginContext(request, loginContext)) + .thenReturn(new OAuthLoginFlowService.AuthenticatedLoginContext( + upstream, + principal)); + CustomOAuth2UserService service = new CustomOAuth2UserService( + loginFlowService, + contextResolver); + + OAuth2User loaded = service.loadUser(request); + + verify(loginFlowService).loadLoginContext( + request, + loginContext); + assertThat((Object) loaded.getAttribute("platformPrincipal")) + .isEqualTo(principal); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java index d32fc6ae..8a0df79c 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/CustomOidcUserServiceTest.java @@ -1,5 +1,16 @@ package com.iflytek.skillhub.auth.oauth; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import java.time.Instant; import java.util.List; @@ -21,54 +32,69 @@ import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - class CustomOidcUserServiceTest { @Test - void loadUser_mapsOidcClaimsThroughExistingLoginFlow() { - OAuthLoginFlowService loginFlowService = mock(OAuthLoginFlowService.class); + void loadUserMapsOidcFactsThroughUnifiedLoginFlow() { + OAuthLoginFlowService loginFlowService = + mock(OAuthLoginFlowService.class); OAuth2UserService delegate = mock(); - CustomOidcUserService service = new CustomOidcUserService(loginFlowService, delegate); + OAuthIdentityLoginContextResolver contextResolver = mock(); + CustomOidcUserService service = + new CustomOidcUserService( + loginFlowService, + delegate, + contextResolver); OidcUserRequest request = oidcRequest(); + IdentityLoginContext loginContext = context(); OidcUser upstreamUser = oidcUser(Map.of( IdTokenClaimNames.SUB, "oidc-sub-1", "email", "user@example.com", "email_verified", true, "preferred_username", "preferred-user", "name", "Display User", - "picture", "https://idp.example/avatar.png" - )); + "picture", "https://idp.example/avatar.png")); PlatformPrincipal platformPrincipal = new PlatformPrincipal( "usr_1", "Preferred User", "user@example.com", "https://idp.example/avatar.png", "okta", - Set.of("USER", "SUPER_ADMIN") - ); + Set.of("USER", "SUPER_ADMIN")); when(delegate.loadUser(request)).thenReturn(upstreamUser); - when(loginFlowService.authenticate(any())).thenReturn(platformPrincipal); + when(contextResolver.current()).thenReturn(loginContext); + when(loginFlowService.authenticate( + eq(request.getClientRegistration()), + any(ProviderAuthenticationResult.class), + eq(loginContext))) + .thenReturn(platformPrincipal); OidcUser loadedUser = service.loadUser(request); - ArgumentCaptor claimsCaptor = ArgumentCaptor.forClass(OAuthClaims.class); - verify(loginFlowService).authenticate(claimsCaptor.capture()); - OAuthClaims claims = claimsCaptor.getValue(); - assertThat(claims.provider()).isEqualTo("okta"); - assertThat(claims.subject()).isEqualTo("oidc-sub-1"); - assertThat(claims.email()).isEqualTo("user@example.com"); - assertThat(claims.emailVerified()).isTrue(); - assertThat(claims.providerLogin()).isEqualTo("preferred-user"); - assertThat(claims.extra()).containsEntry("avatar_url", "https://idp.example/avatar.png"); + ArgumentCaptor resultCaptor = + ArgumentCaptor.forClass( + ProviderAuthenticationResult.class); + verify(loginFlowService).authenticate( + eq(request.getClientRegistration()), + resultCaptor.capture(), + eq(loginContext)); + ProviderAuthenticationResult result = resultCaptor.getValue(); + assertThat(result.primarySubject().type()).isEqualTo("oidc_sub"); + assertThat(result.primarySubject().value()) + .isEqualTo("oidc-sub-1"); + assertThat(result.attributes().get("email").getFirst().value()) + .isEqualTo("user@example.com"); + assertThat(result.attributes().get("email").getFirst().trust()) + .isEqualTo(ProviderAttributeTrust.VERIFIED); + assertThat(result.attributes().get("preferred_username") + .getFirst().value()).isEqualTo("preferred-user"); + assertThat(result.attributes().get("picture").getFirst().value()) + .isEqualTo("https://idp.example/avatar.png"); - assertThat((Object) loadedUser.getAttribute("platformPrincipal")).isEqualTo(platformPrincipal); - assertThat((Object) loadedUser.getAttribute("providerLogin")).isEqualTo("usr_1"); + assertThat((Object) loadedUser.getAttribute("platformPrincipal")) + .isEqualTo(platformPrincipal); + assertThat((Object) loadedUser.getAttribute("providerLogin")) + .isEqualTo("usr_1"); assertThat(loadedUser.getName()).isEqualTo("usr_1"); assertThat(loadedUser.getAuthorities()) .extracting(GrantedAuthority::getAuthority) @@ -76,116 +102,125 @@ class CustomOidcUserServiceTest { } @Test - void toOAuthClaims_fallsBackToNameWhenPreferredUsernameIsMissing() { - OAuthClaims claims = CustomOidcUserService.toOAuthClaims( - oidcRequest(), - oidcUser(Map.of( - IdTokenClaimNames.SUB, "subject-2", - "email", "fallback@example.com", - "email_verified", false, - "name", "Fallback Name" - )) - ); + void conversionPreservesUnverifiedEmailAsUntrustedFact() { + ProviderAuthenticationResult result = + CustomOidcUserService.toProviderAuthenticationResult( + oidcRequest(), + oidcUser(Map.of( + IdTokenClaimNames.SUB, "subject-3", + "email", "unverified@example.com", + "email_verified", false, + "name", "Fallback Name"))); - assertThat(claims.provider()).isEqualTo("okta"); - assertThat(claims.subject()).isEqualTo("subject-2"); - assertThat(claims.email()).isNull(); - assertThat(claims.emailVerified()).isFalse(); - assertThat(claims.providerLogin()).isEqualTo("Fallback Name"); + assertThat(result.primarySubject().value()).isEqualTo("subject-3"); + assertThat(result.attributes().get("email").getFirst().value()) + .isEqualTo("unverified@example.com"); + assertThat(result.attributes().get("email").getFirst().trust()) + .isEqualTo(ProviderAttributeTrust.UNVERIFIED); + assertThat(result.attributes().get("name").getFirst().value()) + .isEqualTo("Fallback Name"); } @Test - void toOAuthClaims_nullsEmailWhenNotVerified() { - OAuthClaims claims = CustomOidcUserService.toOAuthClaims( - oidcRequest(), - oidcUser(Map.of( - IdTokenClaimNames.SUB, "subject-3", - "email", "unverified@example.com", - "email_verified", false, - "preferred_username", "unverified-user" - )) - ); + void conversionTreatsAbsentEmailVerifiedAsUnverified() { + ProviderAuthenticationResult result = + CustomOidcUserService.toProviderAuthenticationResult( + oidcRequest(), + oidcUser(Map.of( + IdTokenClaimNames.SUB, + "subject-absent-verified", + "email", + "maybe@example.com"))); - assertThat(claims.provider()).isEqualTo("okta"); - assertThat(claims.subject()).isEqualTo("subject-3"); - assertThat(claims.email()).isNull(); - assertThat(claims.emailVerified()).isFalse(); - assertThat(claims.providerLogin()).isEqualTo("unverified-user"); + assertThat(result.attributes().get("email").getFirst().trust()) + .isEqualTo(ProviderAttributeTrust.UNVERIFIED); } @Test - void toOAuthClaims_nullsEmailWhenEmailVerifiedClaimIsAbsent() { - OAuthClaims claims = CustomOidcUserService.toOAuthClaims( - oidcRequest(), - oidcUser(Map.of( - IdTokenClaimNames.SUB, "subject-absent-verified", - "email", "maybe@example.com", - "preferred_username", "maybe-user" - )) - ); - - assertThat(claims.email()).isNull(); - assertThat(claims.emailVerified()).isFalse(); - assertThat(claims.providerLogin()).isEqualTo("maybe-user"); - } - - @Test - void toOAuthClaims_throwsWhenSubIsMissing() { + void conversionThrowsWhenSubIsMissing() { OidcUser user = mock(OidcUser.class); - when(user.getClaims()).thenReturn(Map.of("email", "no-sub@example.com")); - assertThatThrownBy(() -> CustomOidcUserService.toOAuthClaims(oidcRequest(), user)) + when(user.getClaims()) + .thenReturn(Map.of("email", "no-sub@example.com")); + + assertThatThrownBy(() -> + CustomOidcUserService.toProviderAuthenticationResult( + oidcRequest(), + user)) .isInstanceOf(OAuth2AuthenticationException.class) .hasMessageContaining("sub"); } @Test - void toOAuthClaims_throwsWhenSubIsBlank() { + void conversionThrowsWhenSubIsBlank() { OidcUser user = mock(OidcUser.class); - when(user.getClaims()).thenReturn(Map.of(IdTokenClaimNames.SUB, " ")); - assertThatThrownBy(() -> CustomOidcUserService.toOAuthClaims(oidcRequest(), user)) + when(user.getClaims()) + .thenReturn(Map.of(IdTokenClaimNames.SUB, " ")); + + assertThatThrownBy(() -> + CustomOidcUserService.toProviderAuthenticationResult( + oidcRequest(), + user)) .isInstanceOf(OAuth2AuthenticationException.class) .hasMessageContaining("sub"); } @Test - void toOAuthClaims_fallsBackToSubWhenAllOtherFieldsMissing() { - OAuthClaims claims = CustomOidcUserService.toOAuthClaims( - oidcRequest(), - oidcUser(Map.of(IdTokenClaimNames.SUB, "only-sub")) - ); + void conversionUsesExactCaseSensitiveSubWhenProfileIsAbsent() { + ProviderAuthenticationResult result = + CustomOidcUserService.toProviderAuthenticationResult( + oidcRequest(), + oidcUser(Map.of( + IdTokenClaimNames.SUB, + "CaseSensitiveSubject"))); - assertThat(claims.subject()).isEqualTo("only-sub"); - assertThat(claims.providerLogin()).isEqualTo("only-sub"); - assertThat(claims.email()).isNull(); - assertThat(claims.emailVerified()).isFalse(); + assertThat(result.primarySubject().value()) + .isEqualTo("CaseSensitiveSubject"); + assertThat(result.attributes().get("sub").getFirst().value()) + .isEqualTo("CaseSensitiveSubject"); + assertThat(result.attributes()).doesNotContainKey("email"); } @Test - void loadUser_deniedWhenOidcEmailNotVerifiedAndPolicyChecksEmail() { - OAuthLoginFlowService loginFlowService = mock(OAuthLoginFlowService.class); + void deniedUnverifiedEmailStillReachesCoreWithUnverifiedTrust() { + OAuthLoginFlowService loginFlowService = + mock(OAuthLoginFlowService.class); OAuth2UserService delegate = mock(); - CustomOidcUserService service = new CustomOidcUserService(loginFlowService, delegate); + OAuthIdentityLoginContextResolver contextResolver = mock(); + CustomOidcUserService service = + new CustomOidcUserService( + loginFlowService, + delegate, + contextResolver); OidcUserRequest request = oidcRequest(); + IdentityLoginContext loginContext = context(); OidcUser upstreamUser = oidcUser(Map.of( IdTokenClaimNames.SUB, "oidc-sub-unverified", "email", "user@company.com", "email_verified", false, - "preferred_username", "unverified-user" - )); + "preferred_username", "unverified-user")); when(delegate.loadUser(request)).thenReturn(upstreamUser); + when(contextResolver.current()).thenReturn(loginContext); - ArgumentCaptor claimsCaptor = ArgumentCaptor.forClass(OAuthClaims.class); - when(loginFlowService.authenticate(claimsCaptor.capture())) + ArgumentCaptor resultCaptor = + ArgumentCaptor.forClass( + ProviderAuthenticationResult.class); + when(loginFlowService.authenticate( + eq(request.getClientRegistration()), + resultCaptor.capture(), + eq(loginContext))) .thenThrow(new OAuth2AuthenticationException( - new org.springframework.security.oauth2.core.OAuth2Error("access_denied"))); + new org.springframework.security.oauth2.core.OAuth2Error( + "access_denied"))); assertThatThrownBy(() -> service.loadUser(request)) .isInstanceOf(OAuth2AuthenticationException.class); - OAuthClaims captured = claimsCaptor.getValue(); - assertThat(captured.email()).isNull(); - assertThat(captured.emailVerified()).isFalse(); - assertThat(captured.subject()).isEqualTo("oidc-sub-unverified"); + ProviderAuthenticationResult captured = resultCaptor.getValue(); + assertThat(captured.attributes().get("email") + .getFirst().trust()) + .isEqualTo(ProviderAttributeTrust.UNVERIFIED); + assertThat(captured.primarySubject().value()) + .isEqualTo("oidc-sub-unverified"); } private static OidcUserRequest oidcRequest() { @@ -194,15 +229,23 @@ class CustomOidcUserServiceTest { "id-token", issuedAt, issuedAt.plusSeconds(300), - Map.of(IdTokenClaimNames.SUB, "request-sub") - ); + Map.of(IdTokenClaimNames.SUB, "request-sub")); OAuth2AccessToken accessToken = new OAuth2AccessToken( OAuth2AccessToken.TokenType.BEARER, "access-token", issuedAt, - issuedAt.plusSeconds(300) - ); - return new OidcUserRequest(clientRegistration(), accessToken, idToken); + issuedAt.plusSeconds(300)); + return new OidcUserRequest( + clientRegistration(), + accessToken, + idToken); + } + + private static IdentityLoginContext context() { + return new IdentityLoginContext( + "req-123", + "203.0.113.9", + "SkillHub Browser"); } private static OidcUser oidcUser(Map claims) { @@ -211,25 +254,27 @@ class CustomOidcUserServiceTest { "id-token", issuedAt, issuedAt.plusSeconds(300), - claims - ); + claims); return new DefaultOidcUser( List.of(new SimpleGrantedAuthority("OIDC_USER")), idToken, - new OidcUserInfo(claims) - ); + new OidcUserInfo(claims)); } private static ClientRegistration clientRegistration() { return ClientRegistration.withRegistrationId("okta") .clientId("client") .clientSecret("secret") - .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .redirectUri("https://skillhub.example/login/oauth2/code/okta") - .authorizationUri("https://idp.example/oauth2/v1/authorize") + .authorizationGrantType( + AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri( + "https://skillhub.example/login/oauth2/code/okta") + .authorizationUri( + "https://idp.example/oauth2/v1/authorize") .tokenUri("https://idp.example/oauth2/v1/token") .jwkSetUri("https://idp.example/oauth2/v1/keys") - .userInfoUri("https://idp.example/oauth2/v1/userinfo") + .userInfoUri( + "https://idp.example/oauth2/v1/userinfo") .userNameAttributeName(IdTokenClaimNames.SUB) .scope("openid", "profile", "email") .build(); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractorTest.java index 35de77b8..96a2f58c 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractorTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractorTest.java @@ -5,6 +5,8 @@ import static org.springframework.test.web.client.match.MockRestRequestMatchers. import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; import java.time.Instant; import java.util.List; import java.util.Map; @@ -35,10 +37,20 @@ class GitHubClaimsExtractorTest { )); GitHubClaimsExtractor extractor = new GitHubClaimsExtractor(restClientBuilder); - OAuthClaims claims = extractor.extract(userRequest(), githubUser("alice@example.com")); + ProviderAuthenticationResult result = + extractor.extract( + userRequest(), + githubUser("alice@example.com")); - assertThat(claims.email()).isEqualTo("alice@example.com"); - assertThat(claims.emailVerified()).isFalse(); + assertThat(result.primarySubject().type()) + .isEqualTo("github_user_id"); + assertThat(result.primarySubject().value()).isEqualTo("42"); + assertThat(result.attributes().get("email").getFirst().value()) + .isEqualTo("alice@example.com"); + assertThat(result.attributes().get("email").getFirst().trust()) + .isEqualTo(ProviderAttributeTrust.UNVERIFIED); + assertThat(result.evidence().protocol()) + .isEqualTo("oauth2-github"); server.verify(); } @@ -59,10 +71,13 @@ class GitHubClaimsExtractorTest { )); GitHubClaimsExtractor extractor = new GitHubClaimsExtractor(restClientBuilder); - OAuthClaims claims = extractor.extract(userRequest(), githubUser(null)); + ProviderAuthenticationResult result = + extractor.extract(userRequest(), githubUser(null)); - assertThat(claims.email()).isEqualTo("alice@example.com"); - assertThat(claims.emailVerified()).isTrue(); + assertThat(result.attributes().get("email").getFirst().value()) + .isEqualTo("alice@example.com"); + assertThat(result.attributes().get("email").getFirst().trust()) + .isEqualTo(ProviderAttributeTrust.VERIFIED); server.verify(); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java index 643be583..24962c6d 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java @@ -5,6 +5,8 @@ import static org.springframework.test.web.client.match.MockRestRequestMatchers. import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; import java.time.Instant; import java.util.Map; import org.junit.jupiter.api.Test; @@ -25,7 +27,7 @@ class GitLabClaimsExtractorTest { RestClient.Builder restClientBuilder = RestClient.builder(); GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder); - OAuthClaims claims = extractor.extract( + ProviderAuthenticationResult result = extractor.extract( userRequest(), new DefaultOAuth2User( java.util.List.of(), @@ -39,9 +41,15 @@ class GitLabClaimsExtractorTest { ) ); - assertThat(claims.email()).isEqualTo("alice@gitlab.example"); - assertThat(claims.emailVerified()).isTrue(); - assertThat(claims.providerLogin()).isEqualTo("alice"); + assertThat(result.primarySubject().type()) + .isEqualTo("gitlab_user_id"); + assertThat(result.primarySubject().value()).isEqualTo("42"); + assertThat(result.attributes().get("email").getFirst().value()) + .isEqualTo("alice@gitlab.example"); + assertThat(result.attributes().get("email").getFirst().trust()) + .isEqualTo(ProviderAttributeTrust.VERIFIED); + assertThat(result.attributes().get("username").getFirst().value()) + .isEqualTo("alice"); } @Test @@ -61,7 +69,7 @@ class GitLabClaimsExtractorTest { )); GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder); - OAuthClaims claims = extractor.extract( + ProviderAuthenticationResult result = extractor.extract( userRequest(), new DefaultOAuth2User( java.util.List.of(), @@ -74,8 +82,10 @@ class GitLabClaimsExtractorTest { ) ); - assertThat(claims.email()).isEqualTo("alice@gitlab.example"); - assertThat(claims.emailVerified()).isTrue(); + assertThat(result.attributes().get("email").getFirst().value()) + .isEqualTo("alice@gitlab.example"); + assertThat(result.attributes().get("email").getFirst().trust()) + .isEqualTo(ProviderAttributeTrust.VERIFIED); server.verify(); } 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 new file mode 100644 index 00000000..0cf7ae02 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/IdentityProviderRouteReadinessFilterTest.java @@ -0,0 +1,153 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +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 jakarta.servlet.FilterChain; +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.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; + +class IdentityProviderRouteReadinessFilterTest { + + private ClientRegistrationRepository registrationRepository; + private IdentityProviderReadinessService readinessService; + private ClientRegistration registration; + private IdentityProviderRouteReadinessFilter filter; + + @BeforeEach + void setUp() { + registrationRepository = mock( + ClientRegistrationRepository.class); + readinessService = mock( + IdentityProviderReadinessService.class); + registration = registration(); + when(registrationRepository.findByRegistrationId("github")) + .thenReturn(registration); + filter = new IdentityProviderRouteReadinessFilter( + registrationRepository, + readinessService); + } + + @Test + void readyAuthorizationRouteContinuesToSpringOAuthFilter() + throws Exception { + FilterChain chain = mock(FilterChain.class); + MockHttpServletRequest request = request( + "/oauth2/authorization/github"); + MockHttpServletResponse response = + new MockHttpServletResponse(); + + filter.doFilter(request, response, chain); + + verify(readinessService).requireReady(registration); + verify(chain).doFilter(request, response); + } + + @Test + void mismatchCallbackIsRejectedBeforeTokenExchange() + 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(); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(503); + assertThat(response.isCommitted()).isFalse(); + verify(chain, never()).doFilter(request, response); + } + + @Test + void disabledAuthorizationRouteIsRejectedBeforeRedirect() + throws Exception { + FilterChain chain = mock(FilterChain.class); + doThrow(new IdentityCoreException( + IdentityFailureCode.PROVIDER_DISABLED)) + .when(readinessService).requireReady(registration); + MockHttpServletRequest request = request( + "/oauth2/authorization/github"); + MockHttpServletResponse response = + new MockHttpServletResponse(); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.isCommitted()).isFalse(); + verify(chain, never()).doFilter(request, response); + } + + @Test + void unknownRouteIsRejectedWithoutEnteringOAuthFilter() + throws Exception { + FilterChain chain = mock(FilterChain.class); + MockHttpServletRequest request = request( + "/oauth2/authorization/unknown"); + MockHttpServletResponse response = + new MockHttpServletResponse(); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.isCommitted()).isFalse(); + verify(readinessService, never()) + .requireReady(registration); + verify(chain, never()).doFilter(request, response); + } + + @Test + void doesNotMisclassifyDownstreamFailureAsReadinessFailure() + throws Exception { + FilterChain chain = mock(FilterChain.class); + MockHttpServletRequest request = request( + "/oauth2/authorization/github"); + MockHttpServletResponse response = + new MockHttpServletResponse(); + doThrow(new IllegalStateException("downstream failure")) + .when(chain).doFilter(request, response); + + assertThatThrownBy(() -> + filter.doFilter(request, response, chain)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("downstream failure"); + } + + private static MockHttpServletRequest request(String uri) { + return new MockHttpServletRequest("GET", uri); + } + + private static ClientRegistration registration() { + return ClientRegistration.withRegistrationId("github") + .clientId("client") + .clientSecret("secret") + .authorizationGrantType( + AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri( + "{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri( + "https://github.com/login/oauth/authorize") + .tokenUri( + "https://github.com/login/oauth/access_token") + .userInfoUri("https://api.github.com/user") + .userNameAttributeName("id") + .clientName("GitHub") + .build(); + } +} 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 357ada33..6cd0b316 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,7 +1,7 @@ package com.iflytek.skillhub.auth.oauth; -import com.iflytek.skillhub.auth.identity.IdentityBindingService; -import com.iflytek.skillhub.auth.policy.AccessPolicy; +import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService; +import com.iflytek.skillhub.auth.identity.TrustedProviderRouteResolver; import jakarta.servlet.http.HttpSession; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -32,8 +32,8 @@ class OAuth2AuthorizationRequestResolverTest { .build(); OAuthLoginFlowService oauthLoginFlowService = new OAuthLoginFlowService( java.util.List.of(), - mock(AccessPolicy.class), - mock(IdentityBindingService.class) + mock(TrustedProviderRouteResolver.class), + mock(ExternalIdentityLoginService.class) ); resolver = new SkillHubOAuth2AuthorizationRequestResolver( new InMemoryClientRegistrationRepository(github), diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthAdapterBoundaryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthAdapterBoundaryTest.java new file mode 100644 index 00000000..12bc9790 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthAdapterBoundaryTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; + +class OAuthAdapterBoundaryTest { + + private static final List> ADAPTERS = List.of( + GitHubClaimsExtractor.class, + GitLabClaimsExtractor.class, + CustomOidcUserService.class); + + private static final List FORBIDDEN_DEPENDENCIES = List.of( + "com/iflytek/skillhub/auth/identity/DefaultResolvedProviderHandle", + "com/iflytek/skillhub/auth/identity/IdentityAssertionFactory", + "com/iflytek/skillhub/auth/identity/PlatformPrincipalFactory", + "com/iflytek/skillhub/auth/session/PlatformSessionService", + "com/iflytek/skillhub/auth/repository/IdentityBindingRepository", + "com/iflytek/skillhub/domain/user/UserAccountRepository", + "jakarta/persistence/", + "org/springframework/data/jpa/"); + + @Test + void adaptersCannotReachCoreFactoriesSessionsOrPersistence() throws IOException { + for (Class adapter : ADAPTERS) { + String bytecode = classFileConstants(adapter); + assertThat(FORBIDDEN_DEPENDENCIES) + .as("forbidden bytecode dependencies of %s", adapter.getName()) + .noneMatch(bytecode::contains); + } + } + + private static String classFileConstants(Class type) throws IOException { + String resource = "/" + type.getName().replace('.', '/') + ".class"; + try (InputStream input = type.getResourceAsStream(resource)) { + assertThat(input) + .as("compiled class resource for %s", type.getName()) + .isNotNull(); + return new String(input.readAllBytes(), StandardCharsets.ISO_8859_1); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthIdentityLoginContextResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthIdentityLoginContextResolverTest.java new file mode 100644 index 00000000..0a2fe0d8 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthIdentityLoginContextResolverTest.java @@ -0,0 +1,56 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +class OAuthIdentityLoginContextResolverTest { + + private final OAuthIdentityLoginContextResolver resolver = + new OAuthIdentityLoginContextResolver(); + + @AfterEach + void clearRequestContext() { + RequestContextHolder.resetRequestAttributes(); + MDC.clear(); + } + + @Test + void extractsCallbackRequestAuditMetadata() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("203.0.113.9"); + request.addHeader("User-Agent", "SkillHub Browser"); + RequestContextHolder.setRequestAttributes( + new ServletRequestAttributes(request)); + MDC.put("requestId", "req-123"); + + IdentityLoginContext context = resolver.current(); + + assertThat(context).isEqualTo(new IdentityLoginContext( + "req-123", + "203.0.113.9", + "SkillHub Browser")); + } + + @Test + void dropsOversizedUntrustedHeadersInsteadOfFailingLogin() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("203.0.113.9"); + request.addHeader("User-Agent", "a".repeat(513)); + RequestContextHolder.setRequestAttributes( + new ServletRequestAttributes(request)); + MDC.put("requestId", "r".repeat(65)); + + IdentityLoginContext context = resolver.current(); + + assertThat(context.requestId()).isNull(); + assertThat(context.clientIp()).isEqualTo("203.0.113.9"); + assertThat(context.userAgent()).isNull(); + } +} 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 391d55cc..e4a8de90 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 @@ -1,73 +1,121 @@ package com.iflytek.skillhub.auth.oauth; -import com.iflytek.skillhub.auth.identity.IdentityBindingService; -import com.iflytek.skillhub.auth.policy.AccessDecision; -import com.iflytek.skillhub.auth.policy.AccessPolicy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService; +import com.iflytek.skillhub.auth.identity.IdentityCoreException; +import com.iflytek.skillhub.auth.identity.IdentityFailureCode; +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome; +import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence; +import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult; +import com.iflytek.skillhub.auth.identity.SubjectCandidate; +import com.iflytek.skillhub.auth.identity.TrustedProviderRouteResolver; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.domain.user.UserStatus; import jakarta.servlet.http.HttpSession; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - class OAuthLoginFlowServiceTest { @Test - void authenticate_allowsPreviouslyApprovedUserWhenPolicyRequiresApproval() { - AccessPolicy accessPolicy = mock(AccessPolicy.class); - IdentityBindingService identityBindingService = mock(IdentityBindingService.class); - OAuthLoginFlowService service = new OAuthLoginFlowService( - List.of(), - accessPolicy, - identityBindingService - ); - OAuthClaims claims = claims(); - PlatformPrincipal approvedPrincipal = new PlatformPrincipal( - "usr_1", "alice", "alice@example.com", null, "github", Set.of("USER")); - when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.PENDING_APPROVAL); - when(identityBindingService.bindOrCreate(claims, UserStatus.PENDING)).thenReturn(approvedPrincipal); + void authenticateReturnsPrincipalOnlyForAuthenticatedOutcome() { + TrustedProviderRouteResolver resolver = + mock(TrustedProviderRouteResolver.class); + ExternalIdentityLoginService identityLoginService = + mock(ExternalIdentityLoginService.class); + OAuthLoginFlowService service = + new OAuthLoginFlowService( + List.of(), + resolver, + identityLoginService); + PlatformPrincipal principal = principal(); + when(identityLoginService.authenticate(any(), any(), any())) + .thenReturn(new IdentityLoginOutcome.Authenticated( + principal, + false, + false)); + ClientRegistration registration = registration(); + IdentityLoginContext context = context(); - PlatformPrincipal principal = service.authenticate(claims); + PlatformPrincipal authenticated = + service.authenticate(registration, result(), context); - assertThat(principal).isSameAs(approvedPrincipal); - verify(identityBindingService).bindOrCreate(claims, UserStatus.PENDING); + assertThat(authenticated).isSameAs(principal); + verify(resolver).resolve(registration); + verify(identityLoginService).authenticate( + any(), + any(), + org.mockito.ArgumentMatchers.eq(context)); } @Test - void authenticate_rejectsDisabledUserWhenPolicyRequiresApproval() { - AccessPolicy accessPolicy = mock(AccessPolicy.class); - IdentityBindingService identityBindingService = mock(IdentityBindingService.class); - OAuthLoginFlowService service = new OAuthLoginFlowService( - List.of(), - accessPolicy, - identityBindingService - ); - OAuthClaims claims = claims(); - when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.PENDING_APPROVAL); - when(identityBindingService.bindOrCreate(claims, UserStatus.PENDING)) - .thenThrow(new AccountDisabledException()); + void pendingOutcomeUsesExistingPendingFailureContract() { + TrustedProviderRouteResolver resolver = + mock(TrustedProviderRouteResolver.class); + ExternalIdentityLoginService identityLoginService = + mock(ExternalIdentityLoginService.class); + OAuthLoginFlowService service = + new OAuthLoginFlowService( + List.of(), + resolver, + identityLoginService); + when(identityLoginService.authenticate(any(), any(), any())) + .thenReturn(new IdentityLoginOutcome.PendingApproval( + "ACCOUNT_PENDING")); - assertThatThrownBy(() -> service.authenticate(claims)) - .isInstanceOf(AccountDisabledException.class); + assertThatThrownBy(() -> + service.authenticate( + registration(), + result(), + context())) + .isInstanceOf(AccountPendingException.class); } @Test - void rememberReturnTo_stores_sanitized_return_target() { - OAuthLoginFlowService service = new OAuthLoginFlowService( - List.of(), - mock(AccessPolicy.class), - mock(IdentityBindingService.class) - ); + void authorityMismatchIsMappedToStableOAuthFailure() { + TrustedProviderRouteResolver resolver = + mock(TrustedProviderRouteResolver.class); + ExternalIdentityLoginService identityLoginService = + mock(ExternalIdentityLoginService.class); + OAuthLoginFlowService service = + new OAuthLoginFlowService( + List.of(), + resolver, + identityLoginService); + when(identityLoginService.authenticate(any(), any(), any())) + .thenThrow(new IdentityCoreException( + IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH)); + + assertThatThrownBy(() -> + service.authenticate( + registration(), + result(), + context())) + .isInstanceOfSatisfying( + OAuth2AuthenticationException.class, + exception -> assertThat( + exception.getError().getErrorCode()) + .isEqualTo( + "provider_authority_mismatch")); + } + + @Test + void rememberReturnToStoresSanitizedReturnTarget() { + OAuthLoginFlowService service = service(); MockHttpServletRequest request = new MockHttpServletRequest(); request.setParameter("returnTo", "/dashboard/publish"); @@ -75,75 +123,104 @@ class OAuthLoginFlowServiceTest { HttpSession session = request.getSession(false); assertThat(session).isNotNull(); - assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)) + assertThat(session.getAttribute( + OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)) .isEqualTo("/dashboard/publish"); } @Test - void resolveFailureRedirect_maps_access_denied_to_user_facing_page() { - OAuthLoginFlowService service = new OAuthLoginFlowService( - List.of(), - mock(AccessPolicy.class), - mock(IdentityBindingService.class) - ); + void resolveFailureRedirectMapsAccessDeniedToUserFacingPage() { + OAuthLoginFlowService service = service(); String redirect = service.resolveFailureRedirect( - new OAuth2AuthenticationException(new OAuth2Error("access_denied")), - "/settings/accounts" - ); + new OAuth2AuthenticationException( + new OAuth2Error("access_denied")), + "/settings/accounts"); assertThat(redirect).isEqualTo("/access-denied"); } @Test - void resolveFailureRedirect_mapsMergedAccountToAccessDenied() { - OAuthLoginFlowService service = new OAuthLoginFlowService( - List.of(), - mock(AccessPolicy.class), - mock(IdentityBindingService.class) - ); - - assertThat(service.resolveFailureRedirect(new AccountMergedException(), null)) - .isEqualTo("/access-denied"); + void resolveFailureRedirectMapsMergedAccountToAccessDenied() { + assertThat(service().resolveFailureRedirect( + new AccountMergedException(), + null)).isEqualTo("/access-denied"); } @Test - void resolveFailureRedirect_mapsSystemAccountToAccessDenied() { - OAuthLoginFlowService service = new OAuthLoginFlowService( - List.of(), - mock(AccessPolicy.class), - mock(IdentityBindingService.class) - ); - - assertThat(service.resolveFailureRedirect(new SystemAccountLoginException(), null)) - .isEqualTo("/access-denied"); + void resolveFailureRedirectMapsSystemAccountToAccessDenied() { + assertThat(service().resolveFailureRedirect( + new SystemAccountLoginException(), + null)).isEqualTo("/access-denied"); } @Test - void consumeReturnTo_clearsUnsafeSessionValue() { - OAuthLoginFlowService service = new OAuthLoginFlowService( - List.of(), - mock(AccessPolicy.class), - mock(IdentityBindingService.class) - ); + void consumeReturnToClearsUnsafeSessionValue() { + OAuthLoginFlowService service = service(); MockHttpServletRequest request = new MockHttpServletRequest(); HttpSession session = request.getSession(true); - session.setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, "https://evil.example"); + session.setAttribute( + OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, + "https://evil.example"); String returnTo = service.consumeReturnTo(session); assertThat(returnTo).isNull(); - assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); + assertThat(session.getAttribute( + OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)) + .isNull(); } - private OAuthClaims claims() { - return new OAuthClaims( - "github", - "gh_1", - "alice@example.com", - true, + private static OAuthLoginFlowService service() { + return new OAuthLoginFlowService( + List.of(), + mock(TrustedProviderRouteResolver.class), + mock(ExternalIdentityLoginService.class)); + } + + private static ProviderAuthenticationResult result() { + return new ProviderAuthenticationResult( + new SubjectCandidate("github_user_id", "123"), + List.of(), + Map.of(), + new ProtocolAuthenticationEvidence( + "oauth2-github", + Instant.parse("2026-07-30T08:00:00Z"), + Set.of("oauth2_authorization_code"))); + } + + private static IdentityLoginContext context() { + return new IdentityLoginContext( + "req-123", + "203.0.113.9", + "SkillHub Browser"); + } + + private static PlatformPrincipal principal() { + return new PlatformPrincipal( + "usr_1", "alice", - Map.of() - ); + "alice@example.com", + null, + "github", + Set.of("USER")); + } + + private static ClientRegistration registration() { + return ClientRegistration.withRegistrationId("github") + .clientId("client") + .clientSecret("secret") + .authorizationGrantType( + AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri( + "{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri( + "https://github.com/login/oauth/authorize") + .tokenUri( + "https://github.com/login/oauth/access_token") + .userInfoUri("https://api.github.com/user") + .userNameAttributeName("id") + .clientName("GitHub") + .build(); } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java index 3bfb7b90..b55fd15f 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java @@ -1,73 +1,140 @@ package com.iflytek.skillhub.auth.policy; -import com.iflytek.skillhub.auth.oauth.OAuthClaims; -import org.junit.jupiter.api.Test; -import java.util.Map; -import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import com.iflytek.skillhub.auth.identity.EmailAssurance; +import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + class AccessPolicyTest { @Test void openPolicy_alwaysAllows() { var policy = new OpenAccessPolicy(); - var claims = new OAuthClaims("github", "123", "user@any.com", true, "user", Map.of()); - assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + assertThat(policy.evaluate(context( + "github", + "123", + "user@any.com", + EmailAssurance.UNVERIFIED))) + .isEqualTo(AccessDecision.ALLOW); } @Test - void emailDomainPolicy_allowsMatchingDomain() { + void emailDomainPolicy_allowsMatchingVerifiedDomain() { var policy = new EmailDomainAccessPolicy(Set.of("company.com")); - var claims = new OAuthClaims("github", "123", "user@company.com", true, "user", Map.of()); - assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + assertThat(policy.evaluate(context( + "github", + "123", + "user@company.com", + EmailAssurance.VERIFIED))) + .isEqualTo(AccessDecision.ALLOW); + } + + @Test + void emailDomainPolicy_allowsAuthoritativeEmail() { + var policy = new EmailDomainAccessPolicy(Set.of("company.com")); + assertThat(policy.evaluate(context( + "github", + "123", + "user@company.com", + EmailAssurance.AUTHORITATIVE))) + .isEqualTo(AccessDecision.ALLOW); } @Test void emailDomainPolicy_deniesNonMatchingDomain() { var policy = new EmailDomainAccessPolicy(Set.of("company.com")); - var claims = new OAuthClaims("github", "123", "user@other.com", true, "user", Map.of()); - assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + assertThat(policy.evaluate(context( + "github", + "123", + "user@other.com", + EmailAssurance.VERIFIED))) + .isEqualTo(AccessDecision.DENY); } @Test - void emailDomainPolicy_deniesNullEmail() { + void emailDomainPolicy_deniesMissingEmail() { var policy = new EmailDomainAccessPolicy(Set.of("company.com")); - var claims = new OAuthClaims("github", "123", null, false, "user", Map.of()); - assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + assertThat(policy.evaluate(new IdentityAccessContext( + "github", + "github_user_id", + "123", + Optional.empty(), + EmailAssurance.UNVERIFIED, + IdentityLoginContext.empty()))) + .isEqualTo(AccessDecision.DENY); } @Test void emailDomainPolicy_deniesUnverifiedEmailFromMatchingDomain() { var policy = new EmailDomainAccessPolicy(Set.of("company.com")); - var claims = new OAuthClaims("github", "123", "user@company.com", false, "user", Map.of()); - assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + assertThat(policy.evaluate(context( + "github", + "123", + "user@company.com", + EmailAssurance.UNVERIFIED))) + .isEqualTo(AccessDecision.DENY); } @Test void providerAllowlistPolicy_allowsMatchingProvider() { var policy = new ProviderAllowlistAccessPolicy(Set.of("github")); - var claims = new OAuthClaims("github", "123", "u@a.com", true, "user", Map.of()); - assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + assertThat(policy.evaluate(context( + "github", + "123", + "u@a.com", + EmailAssurance.VERIFIED))) + .isEqualTo(AccessDecision.ALLOW); } @Test void providerAllowlistPolicy_deniesNonMatchingProvider() { var policy = new ProviderAllowlistAccessPolicy(Set.of("github")); - var claims = new OAuthClaims("google", "123", "u@a.com", true, "user", Map.of()); - assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + assertThat(policy.evaluate(context( + "google", + "123", + "u@a.com", + EmailAssurance.VERIFIED))) + .isEqualTo(AccessDecision.DENY); } @Test void subjectWhitelistPolicy_allowsMatchingSubject() { - var policy = new SubjectWhitelistAccessPolicy(Set.of("github:12345")); - var claims = new OAuthClaims("github", "12345", "u@a.com", true, "user", Map.of()); - assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + var policy = new SubjectWhitelistAccessPolicy( + Set.of("github:12345")); + assertThat(policy.evaluate(context( + "github", + "12345", + "u@a.com", + EmailAssurance.VERIFIED))) + .isEqualTo(AccessDecision.ALLOW); } @Test void subjectWhitelistPolicy_deniesNonMatchingSubject() { - var policy = new SubjectWhitelistAccessPolicy(Set.of("github:12345")); - var claims = new OAuthClaims("github", "99999", "u@a.com", true, "user", Map.of()); - assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + var policy = new SubjectWhitelistAccessPolicy( + Set.of("github:12345")); + assertThat(policy.evaluate(context( + "github", + "99999", + "u@a.com", + EmailAssurance.VERIFIED))) + .isEqualTo(AccessDecision.DENY); + } + + private static IdentityAccessContext context( + String provider, + String subject, + String email, + EmailAssurance assurance) { + return new IdentityAccessContext( + provider, + provider + "_user_id", + subject, + Optional.of(email), + assurance, + IdentityLoginContext.empty()); } } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a99ba1a9..7f766cbb 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1620,6 +1620,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/identity-providers/{providerCode}/authority/recover": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["recoverSameAuthority"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/account/merge/verify": { parameters: { query?: never; @@ -3967,6 +3983,20 @@ export interface components { sortOrder?: number; translations: components["schemas"]["LabelTranslationItemRequest"][]; }; + ApiResponseIdentityProviderAuthorityRecoveryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["IdentityProviderAuthorityRecoveryResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + IdentityProviderAuthorityRecoveryResponse: { + providerCode?: string; + recovered?: boolean; + state?: string; + }; MergeVerifyRequest: { /** Format: int64 */ mergeRequestId: number; @@ -8326,6 +8356,28 @@ export interface operations { }; }; }; + recoverSameAuthority: { + parameters: { + query?: never; + header?: never; + path: { + providerCode: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseIdentityProviderAuthorityRecoveryResponse"]; + }; + }; + }; + }; verify: { parameters: { query?: never;