feat(auth): introduce unified identity core (#643)

Refs #640
This commit is contained in:
XiaoSeS 2026-07-30 18:29:10 +08:00 • committed by GitHub
commit 2978bee316
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
107 changed files with 6072 additions and 1036 deletions

View file

@ -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<String, Object> extra
public record IdentityAccessContext(
String providerCode,
String subjectType,
String subject,
Optional<String> 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

View file

@ -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、

View file

@ -0,0 +1,71 @@
# Issue #640 unified identity core verification
## Scope
- Issue: <https://github.com/iflytek/skillhub/issues/640>
- Pull request: <https://github.com/iflytek/skillhub/pull/643>
- 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.

View file

@ -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.

View file

@ -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<IdentityProviderAuthorityRecoveryResponse>
recoverSameAuthority(
@PathVariable String providerCode,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request) {
return ok(
"response.success.updated",
providerAdminAppService.recoverSameAuthority(
providerCode,
principal.userId(),
AuditRequestContext.from(request)));
}
}

View file

@ -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
) {
}

View file

@ -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<DirectAuthProvider> directAuthProviders;
private final List<PassiveSessionAuthenticator> passiveSessionAuthenticators;
public AuthMethodCatalog(OAuth2ClientProperties oAuth2ClientProperties,
public AuthMethodCatalog(IdentityProviderCatalog identityProviderCatalog,
DirectAuthProperties directAuthProperties,
AuthSessionBootstrapProperties sessionBootstrapProperties,
List<DirectAuthProvider> directAuthProviders,
List<PassiveSessionAuthenticator> 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<AuthProviderResponse> 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<AuthMethodResponse> listMethods(String returnTo) {
String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
List<AuthMethodResponse> 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()) {

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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

View file

@ -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=身份提供方当前状态不允许恢复身份域

View file

@ -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"))

View file

@ -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)));
}
}

View file

@ -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;
}
}

View file

@ -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<IdentityProviderAuthorityRecoveryContext> 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");
}
}

View file

@ -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<MockAuthFilter> 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<MockAuthFilter> 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);

View file

@ -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
}

View file

@ -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;
};
}
}

View file

@ -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<String> authenticationMethods
) {
AuthenticationEvidence {
Objects.requireNonNull(protocol, "protocol");
Objects.requireNonNull(authenticatedAt, "authenticatedAt");
Objects.requireNonNull(authenticationMethods, "authenticationMethods");
authenticationMethods = Set.copyOf(authenticationMethods);
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.auth.identity;
record AuthorityLockEvaluation(
IdentityProviderStatus state,
String persistedFingerprint
) {
boolean ready() {
return state == IdentityProviderStatus.READY;
}
}

View file

@ -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);
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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");
}
}
}

View file

@ -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;
}
}

View file

@ -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");
}
}
}

View file

@ -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);
}

View file

@ -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<EmailClaim> email,
Optional<URI> 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");
}
}
}

View file

@ -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");
}
}
}

View file

@ -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<ExternalSubject> alternateSubjects,
ExternalProfile profile,
Map<String, List<String>> 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<String, List<String>> copied = new LinkedHashMap<>();
mappedAttributes.forEach((key, values) -> copied.put(key, List.copyOf(values)));
mappedAttributes = Map.copyOf(copied);
}
}

View file

@ -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<EmailClaim> email = firstValue(
result.attributes(), descriptor.emailAttributes())
.filter(value -> !value.value().isBlank())
.map(value -> new EmailClaim(
value.value(),
toEmailAssurance(value.trust())
.clampTo(descriptor.emailAssuranceLimit())));
Optional<URI> 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<ProviderAttributeValue> firstValue(
Map<String, List<ProviderAttributeValue>> attributes,
List<String> trustedAttributeOrder) {
for (String attribute : trustedAttributeOrder) {
List<ProviderAttributeValue> 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<String, List<ProviderAttributeValue>> 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);
}
}

View file

@ -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<String> 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();
}
}
}

View file

@ -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;
}
}

View file

@ -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
}

View file

@ -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");
}
}
}

View file

@ -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");
}
}
}

View file

@ -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);
}

View file

@ -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");
}
}
}

View file

@ -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");
}
}

View file

@ -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<IdentityProviderLoginMethod> listReadyProviders();
}

View file

@ -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");
}
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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.
*
* <p>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<IdentityProviderState, String> {
@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);
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.auth.identity;
enum IdentityProviderStatus {
READY,
DISABLED,
MISCONFIGURED,
DEGRADED,
AUTHORITY_MISMATCH,
LEGACY_UNPINNED
}

View file

@ -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<String> 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);
}
}
}

View file

@ -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<String> 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);
}
}

View file

@ -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<String> 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");
}
}
}
}

View file

@ -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() {
}
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.auth.identity;
/**
* Trust level asserted by a protocol adapter for one upstream attribute.
*
* <p>The identity core always clamps this value to the configured provider
* descriptor and never treats it as an authorization decision.</p>
*/
public enum ProviderAttributeTrust {
UNVERIFIED,
ASSERTED,
VERIFIED
}

View file

@ -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");
}
}
}

View file

@ -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.
*
* <p>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.</p>
*/
public record ProviderAuthenticationResult(
SubjectCandidate primarySubject,
List<SubjectCandidate> alternateSubjects,
Map<String, List<ProviderAttributeValue>> 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<String, List<ProviderAttributeValue>> 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<ProviderAttributeValue> 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);
}
}

View file

@ -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);
}
}
}

View file

@ -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());
}
}

View file

@ -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<IdentityProviderState> 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
+ "\"}";
}
}

View file

@ -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<String> allowedSubjectTypes,
SubjectCanonicalizer subjectCanonicalizer,
List<String> displayNameAttributes,
List<String> emailAttributes,
List<String> 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);
}
}

View file

@ -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");
}
}

View file

@ -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.
*
* <p>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<List<ProviderDescriptor>>
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<ProviderDescriptor> 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<IdentityProviderLoginMethod> 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;
}
}
}

View file

@ -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();
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.auth.identity;
record SameAuthorityRecoveryEvaluation(
boolean recovered,
AuthorityLockEvaluation authority
) {
}

View file

@ -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<String, ProviderDescriptor> descriptors;
private final Map<String, ClientRegistration> trustedRegistrations;
@Autowired
StaticTrustedProviderDescriptorSource(
OAuth2ClientProperties properties,
ClientRegistrationRepository registrationRepository,
List<OAuthClaimsExtractor> extractors) {
this(
properties,
registrationRepository,
extractors.stream()
.map(OAuthClaimsExtractor::getProvider)
.collect(Collectors.toUnmodifiableSet()));
}
StaticTrustedProviderDescriptorSource(
OAuth2ClientProperties properties,
ClientRegistrationRepository registrationRepository,
Set<String> extractorCodes) {
Map<String, ProviderDescriptor> resolvedDescriptors =
new LinkedHashMap<>();
Map<String, ClientRegistration> 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<ProviderDescriptor> enabledDescriptors() {
return descriptors.values().stream()
.sorted(Comparator.comparing(
ProviderDescriptor::providerCode))
.toList();
}
private Optional<ResolvedDescriptor> resolveConfiguredRegistration(
String providerCode,
OAuth2ClientProperties.Registration properties,
ClientRegistrationRepository registrationRepository,
Set<String> 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<String> 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<String> displayNameAttributes,
List<String> emailAttributes,
List<String> 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) {
}
}

View file

@ -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");
}
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.auth.identity;
import java.util.List;
interface TrustedProviderDescriptorSource {
ProviderDescriptor require(ResolvedProviderHandle provider);
List<ProviderDescriptor> enabledDescriptors();
}

View file

@ -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);
}

View file

@ -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<String> 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);
}
}

View file

@ -20,14 +20,21 @@ import org.springframework.stereotype.Service;
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
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);

View file

@ -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<OidcUserRequest,
private final OAuthLoginFlowService oauthLoginFlowService;
private final OAuth2UserService<OidcUserRequest, OidcUser> 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<OidcUserRequest, OidcUser> delegate) {
OAuth2UserService<OidcUserRequest, OidcUser> 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<OidcUserRequest,
);
}
static OAuthClaims toOAuthClaims(OidcUserRequest request, OidcUser oidcUser) {
static ProviderAuthenticationResult toProviderAuthenticationResult(
OidcUserRequest request,
OidcUser oidcUser) {
Map<String, Object> 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<OidcUserRequest,
}
String email = asString(claims.get("email"));
boolean emailVerified = Boolean.TRUE.equals(claims.get("email_verified"));
if (!emailVerified) {
email = null;
}
String providerLogin = firstPresent(
asString(claims.get("preferred_username")),
asString(claims.get("name")),
Map<String, List<ProviderAttributeValue>> 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<String, List<ProviderAttributeValue>> attributes,
String key,
Object rawValue,
ProviderAttributeTrust trust) {
if (!(rawValue instanceof String value) || value.isBlank()) {
return;
}
attributes.put(
key,
List.of(new ProviderAttributeValue(value, trust)));
}
}

View file

@ -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<String, Object> 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<String, List<ProviderAttributeValue>> 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<String, List<ProviderAttributeValue>> 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) {

View file

@ -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<String, Object> 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<String, List<ProviderAttributeValue>> 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<String, List<ProviderAttributeValue>> 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) {

View file

@ -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;
}
}

View file

@ -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<String, Object> extra
) {}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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<String, OAuthClaimsExtractor> extractors;
private final AccessPolicy accessPolicy;
private final IdentityBindingService identityBindingService;
private final TrustedProviderRouteResolver providerRouteResolver;
private final ExternalIdentityLoginService identityLoginService;
public OAuthLoginFlowService(List<OAuthClaimsExtractor> 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);
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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<String> 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");
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -11,5 +11,6 @@ import java.util.Optional;
@Repository
public interface IdentityBindingRepository extends JpaRepository<IdentityBinding, Long> {
Optional<IdentityBinding> findByProviderCodeAndSubject(String providerCode, String subject);
boolean existsByProviderCode(String providerCode);
java.util.List<IdentityBinding> findByUserId(String userId);
}

View file

@ -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;
}
}

View file

@ -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<com.iflytek.skillhub.auth.policy.IdentityAccessContext>
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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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<ProviderAttributeValue> loginValues = new ArrayList<>(
values("alice", ProviderAttributeTrust.ASSERTED));
Map<String, List<ProviderAttributeValue>> attributes = new HashMap<>();
attributes.put("login", loginValues);
Set<String> 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<SubjectCandidate> alternates,
Map<String, List<ProviderAttributeValue>> attributes,
String protocol) {
return new ProviderAuthenticationResult(
primary,
alternates,
attributes,
new ProtocolAuthenticationEvidence(
protocol,
AUTHENTICATED_AT,
Set.of("oauth2_authorization_code"))
);
}
private static List<ProviderAttributeValue> values(
String value,
ProviderAttributeTrust trust) {
return List.of(new ProviderAttributeValue(value, trust));
}
}

View file

@ -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<UserAccount> 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<UserAccount> 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<UserAccount> userCaptor = ArgumentCaptor.forClass(UserAccount.class);
verify(userRepo).save(userCaptor.capture());
assertThat(userCaptor.getValue().getEmail()).isNull();
}
}

View file

@ -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<UserAccount> 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<IdentityBinding> 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<UserAccount> 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"));
}
}

View file

@ -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");
}
}

View file

@ -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"));
}
}

View file

@ -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);
}
}

View file

@ -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");
}
}

View file

@ -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);
}
}

View file

@ -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<String, OAuth2ClientProperties.Registration> registrations,
Set<String> 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();
}
}

View file

@ -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");

View file

@ -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);
}
}

View file

@ -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<OidcUserRequest, OidcUser> 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<OAuthClaims> 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<ProviderAuthenticationResult> 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<OidcUserRequest, OidcUser> 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<OAuthClaims> claimsCaptor = ArgumentCaptor.forClass(OAuthClaims.class);
when(loginFlowService.authenticate(claimsCaptor.capture()))
ArgumentCaptor<ProviderAuthenticationResult> 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<String, Object> 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();

View file

@ -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();
}

View file

@ -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();
}

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