mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
commit
2978bee316
107 changed files with 6072 additions and 1036 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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、
|
||||
|
|
|
|||
71
docs/verification/issue-640.md
Normal file
71
docs/verification/issue-640.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=身份提供方当前状态不允许恢复身份域
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
record AuthorityLockEvaluation(
|
||||
IdentityProviderStatus state,
|
||||
String persistedFingerprint
|
||||
) {
|
||||
boolean ready() {
|
||||
return state == IdentityProviderStatus.READY;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
enum IdentityProviderStatus {
|
||||
READY,
|
||||
DISABLED,
|
||||
MISCONFIGURED,
|
||||
DEGRADED,
|
||||
AUTHORITY_MISMATCH,
|
||||
LEGACY_UNPINNED
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
+ "\"}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
record SameAuthorityRecoveryEvaluation(
|
||||
boolean recovered,
|
||||
AuthorityLockEvaluation authority
|
||||
) {
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
interface TrustedProviderDescriptorSource {
|
||||
|
||||
ProviderDescriptor require(ResolvedProviderHandle provider);
|
||||
|
||||
List<ProviderDescriptor> enabledDescriptors();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue