mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-06 02:48:28 +00:00
Merge pull request #654 from iflytek/feature/identity-provider-registry
feat(auth): add unified provider registry and adapter contracts
This commit is contained in:
commit
6e444cb19f
55 changed files with 3316 additions and 837 deletions
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
> 外部身份架构说明:LDAP、DingTalk、CAS、SAML、可信代理及其他新外部身份接入,
|
||||
> 以 [统一身份联邦设计](./21-unified-identity-federation-design.md) 为准。GitHub、
|
||||
> GitLab 和标准 OIDC 已迁入统一身份核心;`DirectAuthProvider` 和
|
||||
> `PassiveSessionAuthenticator` 仍是兼容扩展点,不是新 Provider 的目标契约。新
|
||||
> Provider 只能返回协议验证结果,由统一核心归一化为内部 `IdentityAssertion`,不得
|
||||
> 直接返回 `PlatformPrincipal`。
|
||||
> GitLab 和标准 OIDC 已迁入统一身份核心;Credential、Passive 和 Browser Adapter
|
||||
> 已由 Provider Registry 统一发现和路由。旧名称 `DirectAuthProvider` 和
|
||||
> `PassiveSessionAuthenticator` 仅是待删除的源码迁移别名,已经不能返回
|
||||
> `PlatformPrincipal`。新 Provider 只能返回协议验证结果,由统一核心归一化为内部
|
||||
> `IdentityAssertion`。
|
||||
|
||||
## 0. 身份标识约束
|
||||
|
||||
|
|
@ -249,21 +250,29 @@ protocol、canonical Authority、SHA-256 fingerprint 和状态,不保存 clien
|
|||
|
||||
- 接口:`POST /api/v1/auth/session/bootstrap`
|
||||
- 用途:前端在同域场景下显式触发一次“读取外部会话并尝试换取 skillhub Session”的流程
|
||||
- 默认状态:关闭,开源版不提供任何 `PassiveSessionAuthenticator` 实现
|
||||
- 默认状态:关闭,开源版不提供任何 `PassiveAuthenticationAdapter` 实现
|
||||
- 安全边界:默认不做全局自动登录 filter,避免匿名访问时隐式建会话、放大 CSRF 和审计复杂度
|
||||
|
||||
扩展接口如下:
|
||||
|
||||
```java
|
||||
public interface PassiveSessionAuthenticator {
|
||||
String providerCode();
|
||||
Optional<PlatformPrincipal> authenticate(HttpServletRequest request);
|
||||
public interface PassiveAuthenticationAdapter {
|
||||
ProviderInstanceDefinition provider();
|
||||
Optional<ProviderAuthenticationResult> authenticate(
|
||||
PassiveAuthenticationRequest request
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
约束如下:
|
||||
|
||||
- `authenticate()` 只负责验证外部被动会话并返回平台登录所需主体
|
||||
- Registry 先确认 Provider `READY`,再调用 `authenticate()`
|
||||
- App 层把 Servlet 请求转换成不可变 `PassiveAuthenticationRequest`;Adapter 不能访问
|
||||
`HttpSession`、Servlet API 或 `SecurityContext`
|
||||
- `authenticate()` 只负责验证外部被动会话并返回非敏感协议事实
|
||||
- 统一身份核心负责账号、Binding、审批和 `PlatformPrincipal`
|
||||
- 断言存在但无效、重放或上游不可用时,Adapter 抛出只含稳定失败码的
|
||||
`ProviderAuthenticationException`
|
||||
- 是否允许启用该入口由 `skillhub.auth.session-bootstrap.enabled` 控制,默认 `false`
|
||||
- 未启用时接口返回 `403`
|
||||
- 启用但 provider 不受支持时返回 `400`
|
||||
|
|
@ -275,9 +284,11 @@ public interface PassiveSessionAuthenticator {
|
|||
为兼容未来“前端收集用户名密码,后端调用企业 SSO / RPC 校验”的私有部署模式,开源版增加默认关闭的直连认证抽象:
|
||||
|
||||
```java
|
||||
public interface DirectAuthProvider {
|
||||
String providerCode();
|
||||
PlatformPrincipal authenticate(DirectAuthRequest request);
|
||||
public interface CredentialAuthenticationAdapter {
|
||||
ProviderInstanceDefinition provider();
|
||||
ProviderAuthenticationResult authenticate(
|
||||
CredentialAuthenticationRequest request
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -290,7 +301,10 @@ public interface DirectAuthProvider {
|
|||
- 开源版默认关闭,由 `skillhub.auth.direct.enabled` 控制
|
||||
- 关闭时返回 `403`
|
||||
- provider 不受支持时返回 `400`
|
||||
- provider 认证失败时沿用 provider 自身的认证异常语义
|
||||
- provider 认证失败时使用 `ProviderAuthenticationFailureCode` 的稳定分类
|
||||
- Provider 非 `READY` 时不会把凭证发送给 Adapter
|
||||
- Adapter 不创建账号、Binding、角色或 Session
|
||||
- 凭证无效、TLS、超时、配置或响应错误不会把上游详情写入用户响应
|
||||
- 成功时建立标准 Session,并返回与 `/api/v1/auth/me` 一致的用户结构
|
||||
- 现有 `/api/v1/auth/local/login` 保持不变,兼容层只是新增可选入口
|
||||
|
||||
|
|
|
|||
|
|
@ -1,130 +1,208 @@
|
|||
# 认证扩展与私有 SSO 兼容设计
|
||||
|
||||
> 状态说明:本文记录当前 Direct/Passive 私有 SSO 兼容入口。新外部身份实现和这些入口的
|
||||
> 后续迁移,以 [统一身份联邦设计](./21-unified-identity-federation-design.md) 为准。
|
||||
> `DirectAuthProvider` 和 `PassiveSessionAuthenticator` 直接返回
|
||||
> `PlatformPrincipal` 的方式属于待迁移设计,不应继续扩展到 LDAP、CAS、DingTalk、
|
||||
> SAML 或新的私有 SSO。新 Adapter 只返回协议验证结果,由统一核心归一化为
|
||||
> `IdentityAssertion`。
|
||||
> 本文描述 Provider Registry 落地后的当前扩展边界。完整身份、不变量、迁移顺序和协议
|
||||
> 路线以 [统一身份联邦设计](./21-unified-identity-federation-design.md) 为准。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在不影响当前开源版 OAuth 和本地账号登录能力的前提下,为未来私有仓库接入企业 SSO 预留稳定扩展点,并把代码差异控制在 provider 实现层和少量配置层。
|
||||
SkillHub 保留已有公共登录协议,同时允许受信、随发布物构建的企业认证 Adapter 接入。
|
||||
Adapter 只验证协议并返回外部身份事实;Provider Registry 和统一身份核心负责 Provider
|
||||
路由、Authority 锁定、账号创建、身份绑定、审批、资料同步、角色保护和 Session。
|
||||
|
||||
## 2. 已确认约束
|
||||
不支持上传或热加载第三方 JAR。Adapter 与 SkillHub 运行在同一 JVM,必须经过代码
|
||||
Review 和 Provider Conformance Kit。
|
||||
|
||||
- 私有 SSO 能提供稳定唯一 UID
|
||||
- 用户名密码校验与 Cookie 会话校验都会返回同一稳定 UID
|
||||
- 生产部署预期为 `skill.xxx.com` 与 `sso.xxx.com`
|
||||
- 私有版可通过后端内部接口/RPC 代调用 SSO 校验用户名密码
|
||||
- 首次 SSO 登录自动创建 skillhub 账号
|
||||
- 不做账号合并设计,不依赖 email
|
||||
- 登出联动可保留扩展点,但不是近期目标
|
||||
## 2. 不可绕过的边界
|
||||
|
||||
## 3. 开源版兼容策略
|
||||
- Adapter 不返回或构造 `PlatformPrincipal`。
|
||||
- Adapter 不创建、查询或修改 `UserAccount`、平台角色、Namespace role 或 Binding。
|
||||
- Adapter 不操作 `HttpSession`、`SecurityContext` 或 Redis。
|
||||
- Adapter 不把 provider code、Authority、平台 userId 或角色放入认证结果。
|
||||
- Adapter 不把 token、密码、Cookie、Ticket、Authorization header 或原始上游响应放入
|
||||
`ProviderAuthenticationResult`。
|
||||
- Adapter 的 `provider()` 定义必须来自受信代码和服务端配置,不能来自登录请求或上游
|
||||
响应。
|
||||
- Provider 未启用、配置冲突、Authority 不匹配或状态非 `READY` 时,Registry 不返回
|
||||
路由;Adapter 的网络认证方法不会被调用。
|
||||
|
||||
### 3.1 不改变现有主链路
|
||||
外部 I/O 顺序固定为:
|
||||
|
||||
- 现有 OAuth 登录流程保持不变
|
||||
- 现有本地用户名密码登录保持不变
|
||||
- 现有 `/api/v1/auth/providers` 协议保持不变
|
||||
- 不在开源版中引入私有 SSO 的真实实现
|
||||
|
||||
### 3.2 新增的公共扩展协议
|
||||
|
||||
开源版新增显式被动会话引导接口:
|
||||
|
||||
- `POST /api/v1/auth/session/bootstrap`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "private-sso"
|
||||
}
|
||||
```text
|
||||
Provider Registry READY gate
|
||||
→ Adapter 验证协议或凭证(事务外)
|
||||
→ ProviderAuthenticationResult
|
||||
→ ExternalIdentityLoginService(事务内)
|
||||
→ PlatformPrincipal
|
||||
→ PlatformSessionService
|
||||
```
|
||||
|
||||
行为约束:
|
||||
## 3. 公共协议兼容
|
||||
|
||||
- 默认关闭,由 `skillhub.auth.session-bootstrap.enabled=false` 控制
|
||||
- 关闭时返回 `403`
|
||||
- provider 不存在时返回 `400`
|
||||
- 外部会话校验失败时返回 `401`
|
||||
- 成功时建立 skillhub Session,并返回当前用户信息
|
||||
|
||||
同时新增默认关闭的直连认证兼容接口:
|
||||
以下 HTTP API 保持不变:
|
||||
|
||||
- `GET /api/v1/auth/providers`
|
||||
- `GET /api/v1/auth/methods`
|
||||
- `POST /api/v1/auth/direct/login`
|
||||
- `POST /api/v1/auth/session/bootstrap`
|
||||
- `POST /api/v1/auth/local/login`
|
||||
|
||||
请求:
|
||||
兼容入口仍默认关闭:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "private-sso",
|
||||
"username": "alice",
|
||||
"password": "secret"
|
||||
}
|
||||
```yaml
|
||||
skillhub:
|
||||
auth:
|
||||
direct:
|
||||
enabled: false
|
||||
session-bootstrap:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
行为约束:
|
||||
关闭时返回 `403`;入口开启但 provider 不存在或不具备对应能力时返回 `400`;被动请求中
|
||||
没有有效外部身份时返回 `401`。Provider Authority 不匹配等运行故障返回 `503`。
|
||||
|
||||
- 默认关闭,由 `skillhub.auth.direct.enabled=false` 控制
|
||||
- 关闭时返回 `403`
|
||||
- provider 不存在时返回 `400`
|
||||
- 成功时建立 skillhub Session,并返回当前用户信息
|
||||
- 开源版仍保留原始 `/api/v1/auth/local/login`
|
||||
`provider=local` 的 direct-login 兼容行为保留,但本地密码不属于外部 Provider,也不进入
|
||||
Identity Binding。新的企业认证必须实现下面的 Adapter 契约。
|
||||
|
||||
### 3.3 代码级扩展点
|
||||
## 4. Provider Instance 定义
|
||||
|
||||
Credential 和 Passive Adapter 都声明一个不可变的
|
||||
`ProviderInstanceDefinition`:
|
||||
|
||||
```java
|
||||
public interface PassiveSessionAuthenticator {
|
||||
String providerCode();
|
||||
Optional<PlatformPrincipal> authenticate(HttpServletRequest request);
|
||||
}
|
||||
new ProviderInstanceDefinition(
|
||||
"private-sso",
|
||||
"private-sso",
|
||||
"https://sso.example",
|
||||
"Enterprise SSO",
|
||||
"private_subject",
|
||||
"private_subject",
|
||||
Map.of("private_subject", SubjectNormalization.EXACT),
|
||||
List.of("display_name"),
|
||||
List.of("email"),
|
||||
List.of("avatar_url"),
|
||||
EmailAssurance.VERIFIED,
|
||||
true
|
||||
);
|
||||
```
|
||||
|
||||
字段含义:
|
||||
|
||||
- `providerCode`:稳定 Provider Instance 标识;不能因部署或登录方式变化。
|
||||
- `protocol`:写入认证证据和 Authority fingerprint 的协议代码。
|
||||
- `canonicalAuthority`:该 Provider 所代表的身份域。
|
||||
- `primarySubjectType`:稳定外部主键的类型。
|
||||
- `subjectNormalizations`:核心允许的 Subject 类型和规范化规则。
|
||||
- 属性列表:统一核心可读取的 display name、email、avatar 候选键及优先级。
|
||||
- `emailAssuranceLimit`:该受信 Adapter 允许的 email assurance 上限。
|
||||
- `enabled`:Adapter 能力开关;关闭时不进入目录和路由。
|
||||
|
||||
同一 Provider 同时实现 Credential 和 Passive 能力时,两者返回的定义必须完全一致。
|
||||
Registry 检测到定义或同类能力冲突时,对整个 Provider fail closed。
|
||||
|
||||
## 5. Adapter 契约
|
||||
|
||||
### 5.1 Browser
|
||||
|
||||
Browser 协议保留各自强类型 exchange:
|
||||
|
||||
```java
|
||||
public interface DirectAuthProvider {
|
||||
String providerCode();
|
||||
PlatformPrincipal authenticate(DirectAuthRequest request);
|
||||
public interface BrowserAuthenticationAdapter<T> {
|
||||
ProviderAuthenticationResult authenticate(T exchange);
|
||||
}
|
||||
```
|
||||
|
||||
私有版只需要新增实现,例如:
|
||||
Redirect、Callback、state、nonce、SAML POST 或 CAS Ticket 的传输流程仍由协议模块持有,
|
||||
不使用万能请求对象。现有 GitHub/GitLab claims Adapter 已使用此结果契约;OAuth 路由由
|
||||
Registry 对服务端 `ClientRegistration` 做身份匹配。
|
||||
|
||||
- `private-sso-cookie`:读取共享 Cookie 并向 SSO 校验
|
||||
- 后续如果需要,也可以补“用户名密码直连认证 provider”扩展点
|
||||
### 5.2 Credential
|
||||
|
||||
为减少私有 fork 的前端硬编码,扩展 provider 可额外声明展示名称:
|
||||
```java
|
||||
public interface CredentialAuthenticationAdapter {
|
||||
ProviderInstanceDefinition provider();
|
||||
ProviderAuthenticationResult authenticate(
|
||||
CredentialAuthenticationRequest request
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- `DirectAuthProvider.displayName()` 默认回退为 `providerCode()`
|
||||
- `PassiveSessionAuthenticator.displayName()` 默认回退为 `providerCode()`
|
||||
- `GET /api/v1/auth/methods` 会返回该展示名称,供登录页直接渲染
|
||||
适用于 LDAP bind、企业 RPC 等主动凭证校验。全局入口由
|
||||
`skillhub.auth.direct.enabled` 控制。
|
||||
|
||||
## 4. 本轮已落地内容
|
||||
### 5.3 Passive
|
||||
|
||||
- 新增 `PassiveSessionAuthenticator` SPI
|
||||
- 新增 `DirectAuthProvider` SPI
|
||||
- 新增统一会话建立服务 `PlatformSessionService`
|
||||
- 新增 `POST /api/v1/auth/session/bootstrap` 协议
|
||||
- 新增 `POST /api/v1/auth/direct/login` 协议
|
||||
- 新增 `skillhub.auth.direct.enabled` 开关,默认关闭
|
||||
- 新增 `skillhub.auth.session-bootstrap.enabled` 开关,默认关闭
|
||||
- 前端新增基于运行时配置的账号密码兼容接入层
|
||||
- 前端新增基于运行时配置的被动会话兼容入口
|
||||
- 前端新增显式按钮和可选自动尝试逻辑,默认都不启用
|
||||
- 增加 controller 集成测试,验证:
|
||||
- 默认关闭时不会影响现有系统
|
||||
- 启用并提供 authenticator 时可以建立 skillhub Session
|
||||
```java
|
||||
public interface PassiveAuthenticationAdapter {
|
||||
ProviderInstanceDefinition provider();
|
||||
Optional<ProviderAuthenticationResult> authenticate(
|
||||
PassiveAuthenticationRequest request
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
统一会话建立约束:
|
||||
适用于可信 Header、签名 JWT、已有企业会话和 SPNEGO。App 层只向 Adapter 提供不可变的
|
||||
method、request URI、query、remote address 和 Header 快照;SPI 不依赖 Servlet,
|
||||
Adapter 不能读取或写入 Session、Cookie response 或 Security Context。全局入口由
|
||||
`skillhub.auth.session-bootstrap.enabled` 控制。
|
||||
|
||||
- 本地登录、OAuth 成功回调、direct auth、session bootstrap、mock 登录旁路都走 `PlatformSessionService`
|
||||
- 会话写入统一依赖 `HttpSession` 属性:`platformPrincipal` 与 `SPRING_SECURITY_CONTEXT`
|
||||
- 因此在生产环境启用 Spring Session Redis 时,不需要为不同登录方式分别处理 Session 序列化或存储逻辑
|
||||
- 交互式登录默认轮换 session id;OAuth 这类已在 Spring Security 认证链中的流程复用现有 `Authentication`
|
||||
旧名称 `DirectAuthProvider` 和 `PassiveSessionAuthenticator` 仅保留为待删除的源码迁移
|
||||
别名;它们已经继承新契约,不能再返回 `PlatformPrincipal`。
|
||||
|
||||
前端运行时配置:
|
||||
### 5.4 失败分类
|
||||
|
||||
Adapter 不能抛出携带上游响应、用户名或凭证的自由文本异常。协议校验或上游调用失败时,
|
||||
统一抛出 `ProviderAuthenticationException`,只携带
|
||||
`ProviderAuthenticationFailureCode`:
|
||||
|
||||
- `UPSTREAM_INVALID_CREDENTIALS`、`REPLAY_DETECTED` → 401。
|
||||
- `UPSTREAM_ACCESS_DENIED` → 403。
|
||||
- `UPSTREAM_UNAVAILABLE`、`UPSTREAM_MISCONFIGURED`、
|
||||
`TLS_VALIDATION_FAILED`、`UPSTREAM_INVALID_RESPONSE` → 503。
|
||||
|
||||
`PassiveAuthenticationAdapter` 只有在请求完全没有外部认证信息时返回 `Optional.empty()`;
|
||||
断言存在但无效、过期、重放或无法验证时必须使用稳定失败码,不能伪装成“未登录”。
|
||||
|
||||
## 6. Auth Method Catalog
|
||||
|
||||
`GET /api/v1/auth/methods` 只从 Registry 的 `READY` Provider 和已协商能力投影:
|
||||
|
||||
```text
|
||||
Browser → OAUTH_REDIRECT
|
||||
Credential → DIRECT_PASSWORD
|
||||
Passive → SESSION_BOOTSTRAP
|
||||
```
|
||||
|
||||
返回的 action URL 由核心按能力生成。Adapter 不能提供任意 URL,也不能向目录暴露
|
||||
Authority、endpoint、属性映射或上游错误。
|
||||
|
||||
`GET /api/v1/auth/providers` 继续只返回 Browser/OAuth 兼容目录,保持旧前端兼容。
|
||||
|
||||
## 7. 从旧 SPI 迁移
|
||||
|
||||
旧实现如果执行了以下操作,必须删除:
|
||||
|
||||
- 按外部 UID 自行查询或创建平台用户。
|
||||
- 自行创建 Identity Binding。
|
||||
- 从 email、username 或 Provider login 推导平台 userId。
|
||||
- 构造 `PlatformPrincipal` 或授予角色。
|
||||
- 在 Adapter 中建立 Session。
|
||||
|
||||
迁移步骤:
|
||||
|
||||
1. 把稳定 provider code、protocol、Authority、Subject 类型和属性映射写入
|
||||
`ProviderInstanceDefinition`。
|
||||
2. 把外部 UID 转成 `SubjectCandidate`。
|
||||
3. 把非敏感资料转成带 `ProviderAttributeTrust` 的属性。
|
||||
4. 返回协议一致的 `ProtocolAuthenticationEvidence`。
|
||||
5. 让统一身份核心按 `AUTO`、`APPROVAL` 或 `EXISTING_BINDING_ONLY` 完成账号和 Binding。
|
||||
6. 为 Adapter 增加 Conformance、稳定失败码、超时和无敏感日志测试。
|
||||
|
||||
具体实现示例见
|
||||
[私有 SSO 接入手册](./12-private-sso-integration-playbook.md)。
|
||||
|
||||
## 8. 前端与部署
|
||||
|
||||
前端运行时配置保持不变:
|
||||
|
||||
- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED`
|
||||
- `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER`
|
||||
|
|
@ -132,24 +210,6 @@ public interface DirectAuthProvider {
|
|||
- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER`
|
||||
- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO`
|
||||
|
||||
使用方式:
|
||||
|
||||
1. 若要做密码直连,后端启用 `skillhub.auth.direct.enabled=true`
|
||||
2. 私有版提供 `DirectAuthProvider` 实现
|
||||
3. 前端设置 `SKILLHUB_WEB_AUTH_DIRECT_*`
|
||||
4. 若要做被动会话,后端启用 `skillhub.auth.session-bootstrap.enabled=true`
|
||||
5. 私有版提供 `PassiveSessionAuthenticator` 实现
|
||||
6. 前端设置 bootstrap provider 和开关
|
||||
7. 登录页显示兼容入口,或在配置允许时自动尝试一次 bootstrap
|
||||
|
||||
## 5. 后续建议
|
||||
|
||||
- 私有版实现 `DirectAuthProvider` 和 / 或 `PassiveSessionAuthenticator` 时,只扩展 provider 层,不复制 session 建立逻辑
|
||||
- 私有版优先采用显式 bootstrap,而不是透明全局拦截器自动登录
|
||||
- 如后续需要登出联动,只通过 `LogoutPropagationHandler` 扩展,不改动现有主登出链路
|
||||
|
||||
## 6. 实施手册
|
||||
|
||||
更详细的私有 SSO 接入步骤、最佳实践、测试矩阵和给后续 coding agent 的执行约束,见:
|
||||
|
||||
- [12-private-sso-integration-playbook.md](./12-private-sso-integration-playbook.md)
|
||||
后端开关、Provider definition 的 `enabled` 和前端开关需要同时满足。建议先启用
|
||||
Credential,再人工验证 Passive Cookie/Header 的作用域、SameSite、Secure、CSRF 和代理
|
||||
信任边界,最后才评估自动 bootstrap。
|
||||
|
|
|
|||
|
|
@ -1,285 +1,242 @@
|
|||
# 私有 SSO 接入兼容层实施手册
|
||||
# 私有 SSO 接入手册
|
||||
|
||||
> 状态说明:本文是现有私有 SSO 兼容入口的历史实施手册。新接入应先遵循
|
||||
> [统一身份联邦设计](./21-unified-identity-federation-design.md),由 Provider 返回协议
|
||||
> 验证结果,再由统一核心归一化为 `IdentityAssertion`;不得直接构造
|
||||
> `PlatformPrincipal`。本文中相反的代码示例只用于理解当前兼容实现。
|
||||
本文给出 Provider Registry 架构下的私有 SSO 实施步骤。开始前先阅读:
|
||||
|
||||
## 1. 文档目的
|
||||
- [认证扩展与私有 SSO 兼容设计](./11-auth-extensibility-and-private-sso.md)
|
||||
- [统一身份联邦设计](./21-unified-identity-federation-design.md)
|
||||
|
||||
本文档面向两类读者:
|
||||
## 1. 先决定交互能力
|
||||
|
||||
- 后续在私有仓库中接入企业 SSO 的开发者
|
||||
- 需要基于当前开源版兼容层继续开发的 coding agent
|
||||
根据真实上游能力选择 Adapter,不要实现万能 Provider:
|
||||
|
||||
本文档不是认证架构总览,而是实施手册。目标是让后续执行者在不了解全部历史上下文的情况下,也能基于当前成果直接开始接入工作,并且尽量把私有仓库与开源仓库的差异控制在 provider 实现层和少量配置层。
|
||||
| 上游能力 | SkillHub Adapter |
|
||||
|---|---|
|
||||
| 浏览器 Redirect/Callback | `BrowserAuthenticationAdapter<T>` |
|
||||
| 用户名密码、LDAP bind、企业 RPC | `CredentialAuthenticationAdapter` |
|
||||
| 可信 Cookie/Header/JWT、已有企业会话 | `PassiveAuthenticationAdapter` |
|
||||
|
||||
相关文档:
|
||||
一个 Provider Instance 可以同时具备 Credential 和 Passive 能力。两种能力必须使用同一个
|
||||
provider code、Authority、Subject 语义和 `ProviderInstanceDefinition`。
|
||||
|
||||
- [03-authentication-design.md](./03-authentication-design.md)
|
||||
- [06-api-design.md](./06-api-design.md)
|
||||
- [08-frontend-architecture.md](./08-frontend-architecture.md)
|
||||
- [11-auth-extensibility-and-private-sso.md](./11-auth-extensibility-and-private-sso.md)
|
||||
## 2. 固定 Provider Instance
|
||||
|
||||
## 2. 当前上下文与已确认约束
|
||||
|
||||
本轮改造的真实目标不是在开源版里实现私有 SSO,而是先把开源版前后端改造成一个稳定的兼容接入层。
|
||||
|
||||
已经确认的业务前提如下:
|
||||
|
||||
- 私有 SSO 能返回稳定且唯一的 UID
|
||||
- 用户名密码校验接口与基于 Cookie 的会话校验接口都返回同一个 UID
|
||||
- SkillHub 私有版与私有 SSO 会部署在统一主域下,例如 `skill.xxx.com` 与 `sso.xxx.com`
|
||||
- 私有版可以通过内部接口或 RPC 调用 SSO 的用户名密码校验能力
|
||||
- 首次 SSO 登录自动创建 SkillHub 账号
|
||||
- 不考虑账号合并
|
||||
- 不依赖 email 字段
|
||||
- 不要求联动登出,但可保留低优先级扩展点
|
||||
|
||||
这意味着后续私有 SSO 的正确接入方式是:
|
||||
|
||||
- 把 SSO 建模为新的认证来源 `private-sso`
|
||||
- 用 `providerCode + subject` 表示外部身份,其中 `subject` 就是 SSO UID
|
||||
- 复用当前平台的统一 Session 建立逻辑,而不是再造一套登录态机制
|
||||
|
||||
## 3. 当前兼容层已经提供了什么
|
||||
|
||||
### 3.1 后端扩展点
|
||||
|
||||
当前开源版已经提供以下后端兼容能力:
|
||||
|
||||
- `DirectAuthProvider`
|
||||
- 用于“前端收集用户名密码,后端调用外部系统校验”的模式
|
||||
- `PassiveSessionAuthenticator`
|
||||
- 用于“浏览器自动带上 SSO Cookie,后端读取请求并向 SSO 校验”的模式
|
||||
- `PlatformSessionService`
|
||||
- 用于统一建立 SkillHub Web Session
|
||||
- `LogoutPropagationHandler`
|
||||
- 用于未来低优先级登出联动
|
||||
|
||||
关键代码位置:
|
||||
|
||||
- [DirectAuthProvider.java](../server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java)
|
||||
- [PassiveSessionAuthenticator.java](../server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java)
|
||||
- [PlatformSessionService.java](../server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java)
|
||||
|
||||
### 3.2 后端公共协议
|
||||
|
||||
当前开源版已经提供以下兼容协议:
|
||||
|
||||
- `POST /api/v1/auth/direct/login`
|
||||
- `POST /api/v1/auth/session/bootstrap`
|
||||
- `GET /api/v1/auth/methods`
|
||||
|
||||
这些协议的设计原则如下:
|
||||
|
||||
- 默认关闭
|
||||
- 默认没有私有 SSO 实现
|
||||
- 启用后由 provider 扩展驱动
|
||||
- 成功后统一建立标准 Spring Security Session
|
||||
- 不替换现有 `/api/v1/auth/local/login`
|
||||
- 不替换现有 OAuth 登录
|
||||
|
||||
### 3.3 前端兼容层
|
||||
|
||||
当前开源版前端已经支持通过运行时配置开启兼容入口:
|
||||
|
||||
- `SKILLHUB_WEB_AUTH_DIRECT_ENABLED`
|
||||
- `SKILLHUB_WEB_AUTH_DIRECT_PROVIDER`
|
||||
- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED`
|
||||
- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER`
|
||||
- `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO`
|
||||
|
||||
前端设计原则如下:
|
||||
|
||||
- 默认不启用任何私有登录入口
|
||||
- 开启后通过兼容层切换,不破坏现有登录页默认行为
|
||||
- 优先走统一目录接口 `/api/v1/auth/methods`
|
||||
- 被动会话登录优先使用显式 bootstrap,而不是页面加载时偷偷尝试多次
|
||||
|
||||
## 4. 私有 SSO 的推荐接入方案
|
||||
|
||||
### 4.1 推荐总策略
|
||||
|
||||
最佳实践不是只选一种方式,而是同时支持两条链路:
|
||||
|
||||
1. 主路径:`DirectAuthProvider`
|
||||
- 登录页展示企业 SSO 用户名密码表单
|
||||
- 后端通过内部接口或 RPC 调用私有 SSO 校验
|
||||
- 校验成功后给用户建立 SkillHub Session
|
||||
|
||||
2. 补充路径:`PassiveSessionAuthenticator`
|
||||
- 当用户已经在 SSO 系统登录过,并且浏览器会自动带上共享 Cookie 时
|
||||
- 登录页允许用户主动点击“从企业 SSO 登录”
|
||||
- 或在非常谨慎的前提下自动尝试一次 bootstrap
|
||||
|
||||
这样做的理由:
|
||||
|
||||
- 覆盖“尚未登录 SSO”和“已登录 SSO”两种用户状态
|
||||
- 不依赖浏览器一定已持有 Cookie
|
||||
- 不把所有登录成功率押在 Cookie 域、SameSite、过期策略等细节上
|
||||
- 不改变开源版原始登录逻辑
|
||||
|
||||
### 4.2 不推荐的做法
|
||||
|
||||
以下做法不建议在私有版采用:
|
||||
|
||||
- 在全局 servlet filter 中对所有匿名请求自动尝试 SSO 登录
|
||||
- 直接在 controller、filter 或 provider 里手写 `HttpSession` 和 `SecurityContext` 逻辑
|
||||
- 把私有 SSO 的 UID 映射成临时整数 ID 再作为用户主标识
|
||||
- 按 email 自动合并账号
|
||||
- 让前端直接调用私有 SSO 的内部校验接口
|
||||
- 为私有版新增一整套与开源版平行的“私有登录 session 机制”
|
||||
|
||||
## 5. 私有版最小差异实施方案
|
||||
|
||||
### 5.1 后端应新增什么
|
||||
|
||||
私有仓库建议只新增以下实现类,不改主链路:
|
||||
|
||||
1. 一个 `DirectAuthProvider` 实现
|
||||
2. 一个 `PassiveSessionAuthenticator` 实现
|
||||
3. 可选的 `LogoutPropagationHandler` 实现
|
||||
4. 私有配置属性类或私有配置项
|
||||
5. 若 SSO 返回的是外部 UID 而不是现成平台用户,需要补充“根据 SSO UID 查询或创建平台用户”的私有服务
|
||||
|
||||
建议命名示例:
|
||||
|
||||
- `PrivateSsoDirectAuthProvider`
|
||||
- `PrivateSsoPassiveSessionAuthenticator`
|
||||
- `PrivateSsoLogoutPropagationHandler`
|
||||
- `PrivateSsoProperties`
|
||||
- `PrivateSsoIdentityService`
|
||||
|
||||
不建议修改这些公共类的职责:
|
||||
|
||||
- `PlatformSessionService`
|
||||
- `LocalAuthController`
|
||||
- `AuthController`
|
||||
- `SecurityConfig`
|
||||
|
||||
### 5.2 后端建议实现步骤
|
||||
|
||||
#### 步骤 1:定义 provider code
|
||||
|
||||
私有版统一使用稳定 provider code:
|
||||
先确定稳定值:
|
||||
|
||||
```text
|
||||
private-sso
|
||||
providerCode: private-sso
|
||||
protocol: private-sso
|
||||
canonicalAuthority: https://sso.example
|
||||
primarySubjectType: private_subject
|
||||
```
|
||||
|
||||
要求:
|
||||
选择原则:
|
||||
|
||||
- `DirectAuthProvider.providerCode()` 和 `PassiveSessionAuthenticator.providerCode()` 返回同一个值
|
||||
- 不要为“用户名密码登录”和“Cookie 登录”定义两个不同 provider code
|
||||
- 如需更友好的登录页文案,请同时覆盖 provider 的 `displayName()`,避免前端再维护一份私有显示名映射
|
||||
- `providerCode` 标识一个身份域,不标识某个登录按钮。
|
||||
- `canonicalAuthority` 必须能区分不同租户、目录或 SSO 集群。
|
||||
- Subject 必须来自不可变外部主键,例如 UID、entryUUID 或 OIDC `sub`。
|
||||
- username、display name 和 email 都不能作为默认 Subject。
|
||||
- 已产生 Binding 后不能静默改变 protocol、Authority 或 Subject 规范化。
|
||||
|
||||
#### 步骤 2:封装 SSO 客户端
|
||||
建议由一个配置组件构造并复用定义:
|
||||
|
||||
不要在 provider 实现里直接散落 HTTP 或 RPC 调用。建议先抽一层私有客户端:
|
||||
```java
|
||||
@ConfigurationProperties("private-sso")
|
||||
public class PrivateSsoProperties {
|
||||
private boolean enabled;
|
||||
private URI authority;
|
||||
private Duration connectTimeout;
|
||||
private Duration readTimeout;
|
||||
// getters/setters
|
||||
}
|
||||
|
||||
@Component
|
||||
public class PrivateSsoProviderDefinition {
|
||||
private final PrivateSsoProperties properties;
|
||||
|
||||
public ProviderInstanceDefinition get() {
|
||||
return new ProviderInstanceDefinition(
|
||||
"private-sso",
|
||||
"private-sso",
|
||||
properties.getAuthority().toString(),
|
||||
"Enterprise SSO",
|
||||
"private_subject",
|
||||
"private_subject",
|
||||
Map.of("private_subject", SubjectNormalization.EXACT),
|
||||
List.of("display_name"),
|
||||
List.of("email"),
|
||||
List.of("avatar_url"),
|
||||
EmailAssurance.VERIFIED,
|
||||
properties.isEnabled()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`provider()` 只读取已经绑定和校验的服务端配置,不连接上游。缺少 Authority、超时或
|
||||
凭证配置时,应让 Adapter 不注册、返回 disabled definition,或在启动校验中明确失败;
|
||||
不能先进入登录目录,再在用户提交凭证后发现配置缺失。
|
||||
|
||||
## 3. 封装上游客户端
|
||||
|
||||
协议 I/O 与结果映射分开:
|
||||
|
||||
```java
|
||||
public interface PrivateSsoClient {
|
||||
PrivateSsoUser verifyPassword(String username, String password);
|
||||
Optional<PrivateSsoUser> verifySession(HttpServletRequest request);
|
||||
Optional<PrivateSsoUser> verifySession(
|
||||
PassiveAuthenticationRequest request
|
||||
);
|
||||
}
|
||||
|
||||
public record PrivateSsoUser(
|
||||
String stableUid,
|
||||
String displayName,
|
||||
String email,
|
||||
boolean emailVerified,
|
||||
URI avatarUrl,
|
||||
Instant authenticatedAt
|
||||
) {}
|
||||
```
|
||||
|
||||
其中 `PrivateSsoUser` 至少应包含:
|
||||
客户端要求:
|
||||
|
||||
- `uid`
|
||||
- `username`
|
||||
- `displayName`
|
||||
- 明确 connect/read timeout;不得无限等待。
|
||||
- HTTPS 校验证书和主机名;不能提供“跳过 TLS 校验”开关。
|
||||
- 不记录密码、Cookie、Ticket、Authorization header、token 或完整上游响应。
|
||||
- 401/403、5xx、timeout、TLS 和响应格式错误转换为
|
||||
`ProviderAuthenticationException` 的稳定失败码;异常 message 和 cause 不进入用户响应
|
||||
或普通业务日志。
|
||||
- 不在数据库事务中执行网络 I/O。
|
||||
- 上游返回的 provider code、Authority、角色和平台 userId 一律忽略。
|
||||
|
||||
最佳实践:
|
||||
## 4. 映射统一认证结果
|
||||
|
||||
- 所有超时、重试、日志脱敏、错误码翻译都放在客户端层
|
||||
- provider 层只负责把外部结果映射成平台所需的身份对象
|
||||
- 禁止记录明文密码
|
||||
|
||||
#### 步骤 3:实现用户映射服务
|
||||
|
||||
私有 SSO 不依赖 email,也不做账号合并,因此建议私有版实现一个专用服务:
|
||||
把上游用户映射为纯协议事实:
|
||||
|
||||
```java
|
||||
public interface PrivateSsoIdentityService {
|
||||
PlatformPrincipal resolveOrCreate(PrivateSsoUser ssoUser);
|
||||
final class PrivateSsoResultMapper {
|
||||
|
||||
ProviderAuthenticationResult map(PrivateSsoUser user) {
|
||||
Map<String, List<ProviderAttributeValue>> attributes =
|
||||
new LinkedHashMap<>();
|
||||
put(attributes, "display_name", user.displayName(),
|
||||
ProviderAttributeTrust.ASSERTED);
|
||||
put(attributes, "email", user.email(),
|
||||
user.emailVerified()
|
||||
? ProviderAttributeTrust.VERIFIED
|
||||
: ProviderAttributeTrust.UNVERIFIED);
|
||||
put(attributes, "avatar_url",
|
||||
user.avatarUrl() == null ? null : user.avatarUrl().toString(),
|
||||
ProviderAttributeTrust.ASSERTED);
|
||||
|
||||
return new ProviderAuthenticationResult(
|
||||
new SubjectCandidate(
|
||||
"private_subject",
|
||||
user.stableUid()
|
||||
),
|
||||
List.of(),
|
||||
attributes,
|
||||
new ProtocolAuthenticationEvidence(
|
||||
"private-sso",
|
||||
user.authenticatedAt(),
|
||||
Set.of("password")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
推荐逻辑:
|
||||
结果中不能加入 token、密码、Cookie、Ticket、Authorization header、原始 JSON/XML、
|
||||
platform userId 或角色。统一核心会再次校验 protocol、Subject allowlist、载荷大小和
|
||||
email assurance 上限。
|
||||
|
||||
1. 按 `providerCode=private-sso` 和 `subject=ssoUid` 查现有绑定
|
||||
2. 若已存在,加载对应平台用户
|
||||
3. 若不存在,则自动创建平台用户
|
||||
4. 创建新的身份绑定
|
||||
5. 返回 `PlatformPrincipal`
|
||||
|
||||
要求:
|
||||
|
||||
- 自动创建出的用户默认应是 `ACTIVE`
|
||||
- 不要尝试和现有本地账号或 OAuth 账号按 email 合并
|
||||
|
||||
#### 步骤 4:实现 `DirectAuthProvider`
|
||||
|
||||
伪代码如下:
|
||||
## 5. 实现 Credential Adapter
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class PrivateSsoDirectAuthProvider implements DirectAuthProvider {
|
||||
public class PrivateSsoCredentialAdapter
|
||||
implements CredentialAuthenticationAdapter {
|
||||
|
||||
private final PrivateSsoProviderDefinition definition;
|
||||
private final PrivateSsoClient client;
|
||||
private final PrivateSsoResultMapper mapper;
|
||||
|
||||
@Override
|
||||
public String providerCode() {
|
||||
return "private-sso";
|
||||
public ProviderInstanceDefinition provider() {
|
||||
return definition.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlatformPrincipal authenticate(DirectAuthRequest request) {
|
||||
PrivateSsoUser ssoUser = privateSsoClient.verifyPassword(
|
||||
public ProviderAuthenticationResult authenticate(
|
||||
CredentialAuthenticationRequest request) {
|
||||
PrivateSsoUser user = client.verifyPassword(
|
||||
request.username(),
|
||||
request.password()
|
||||
);
|
||||
return privateSsoIdentityService.resolveOrCreate(ssoUser);
|
||||
return mapper.map(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
Adapter 不查询或创建 SkillHub 用户,不建立 Binding,不决定审批状态,也不建立 Session。
|
||||
|
||||
- 只返回认证成功后的 `PlatformPrincipal`
|
||||
- 不在这里建立 Session
|
||||
- 不在这里写 `SecurityContext`
|
||||
|
||||
#### 步骤 5:实现 `PassiveSessionAuthenticator`
|
||||
|
||||
伪代码如下:
|
||||
## 6. 实现 Passive Adapter
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class PrivateSsoPassiveSessionAuthenticator implements PassiveSessionAuthenticator {
|
||||
public class PrivateSsoPassiveAdapter
|
||||
implements PassiveAuthenticationAdapter {
|
||||
|
||||
private final PrivateSsoProviderDefinition definition;
|
||||
private final PrivateSsoClient client;
|
||||
private final PrivateSsoResultMapper mapper;
|
||||
|
||||
@Override
|
||||
public String providerCode() {
|
||||
return "private-sso";
|
||||
public ProviderInstanceDefinition provider() {
|
||||
return definition.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PlatformPrincipal> authenticate(HttpServletRequest request) {
|
||||
return privateSsoClient.verifySession(request)
|
||||
.map(privateSsoIdentityService::resolveOrCreate);
|
||||
public Optional<ProviderAuthenticationResult> authenticate(
|
||||
PassiveAuthenticationRequest request) {
|
||||
return client.verifySession(request).map(mapper::map);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
Passive Adapter 只能读取 App 层生成的不可变请求快照;该值对象不提供 Servlet、
|
||||
Session 或 `SecurityContext` 能力。不要重定向或写 Cookie。无有效外部会话时返回
|
||||
`Optional.empty()`。
|
||||
|
||||
- 只消费当前请求已带上的 Cookie 或其他被动凭证
|
||||
- 不主动重定向到 SSO
|
||||
- 不在这里自行创建 Session
|
||||
## 7. 配置 Provisioning 和资料同步
|
||||
|
||||
#### 步骤 6:开启配置
|
||||
统一身份核心按 Provider 配置处理首次登录:
|
||||
|
||||
私有版部署时启用:
|
||||
```yaml
|
||||
skillhub:
|
||||
auth:
|
||||
identity:
|
||||
providers:
|
||||
private-sso:
|
||||
provisioning-mode: APPROVAL
|
||||
profile-sync:
|
||||
display-name: INITIAL_ONLY
|
||||
email: FILL_IF_EMPTY
|
||||
avatar-url: PRESERVE_LOCAL
|
||||
```
|
||||
|
||||
模式:
|
||||
|
||||
- `AUTO`:首次登录自动创建账号和 Binding。
|
||||
- `APPROVAL`:创建 PENDING 账号,管理员批准后才可登录。
|
||||
- `EXISTING_BINDING_ONLY`:只允许预先建立的 Binding。
|
||||
|
||||
不要在 Adapter 中实现上述分支。email 碰撞只会得到 `LINK_REQUIRED`,不会自动绑定或
|
||||
合并账号。
|
||||
|
||||
## 8. 开启兼容入口
|
||||
|
||||
后端:
|
||||
|
||||
```yaml
|
||||
skillhub:
|
||||
|
|
@ -290,154 +247,64 @@ skillhub:
|
|||
enabled: true
|
||||
```
|
||||
|
||||
建议:
|
||||
前端:
|
||||
|
||||
- 预发环境先只开 direct auth
|
||||
- passive bootstrap 在确认 Cookie 域和 SameSite 行为可靠后再开启
|
||||
```text
|
||||
SKILLHUB_WEB_AUTH_DIRECT_ENABLED=true
|
||||
SKILLHUB_WEB_AUTH_DIRECT_PROVIDER=private-sso
|
||||
SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED=true
|
||||
SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER=private-sso
|
||||
```
|
||||
|
||||
## 6. 前端最佳实践
|
||||
建议先只开启 Credential。Passive 需要确认代理信任、Cookie domain/path、SameSite、
|
||||
Secure 和 CSRF 后再开启;自动 bootstrap 最后评估。
|
||||
|
||||
### 6.1 推荐的登录页策略
|
||||
## 9. Provider Conformance
|
||||
|
||||
私有版推荐保留当前开源登录页结构,但增加企业 SSO 入口:
|
||||
Adapter 测试应复用 `ProviderConformanceKit`,并补充协议专属 fixture:
|
||||
|
||||
- 保留 OAuth 按钮
|
||||
- 本地账号登录是否保留,由私有版自行决定
|
||||
- 增加企业 SSO 用户名密码表单,或将现有密码表单切换到 direct auth 兼容接口
|
||||
- 增加“从企业 SSO 登录”按钮,对应 `session/bootstrap`
|
||||
- definition 连续读取稳定且与预期 provider code/Authority 一致。
|
||||
- Subject 稳定、非空、类型在 allowlist。
|
||||
- evidence protocol 与 definition 一致。
|
||||
- email attribute 的 trust 不超过 definition 的 `emailAssuranceLimit`。
|
||||
- 认证结果不含 secret-bearing attribute。
|
||||
- 401/403、5xx、timeout、TLS、响应格式错误使用
|
||||
`ProviderAuthenticationFailureCode` 分类正确。
|
||||
- disabled/misconfigured 时 Registry 不返回路由,认证方法零调用。
|
||||
- 日志不含用户名密码、token、Cookie、Ticket 或完整响应。
|
||||
- Adapter class 不依赖账号、Binding、角色、Session 或 JPA Repository。
|
||||
- Credential 与 Passive 使用同一 Provider 时 definition 完全一致。
|
||||
|
||||
推荐优先级:
|
||||
至少准备以下测试:
|
||||
|
||||
1. 首先提供明确可见的企业用户名密码登录
|
||||
2. 其次提供“从企业 SSO 登录”按钮
|
||||
3. 最后才考虑自动 bootstrap
|
||||
```text
|
||||
valid credential
|
||||
invalid credential
|
||||
upstream timeout
|
||||
TLS failure
|
||||
malformed response
|
||||
disabled provider
|
||||
misconfigured provider
|
||||
stable subject fixture
|
||||
verified/unverified email
|
||||
repeated login reuses binding
|
||||
APPROVAL and EXISTING_BINDING_ONLY
|
||||
```
|
||||
|
||||
### 6.2 自动 bootstrap 的使用建议
|
||||
并发首次登录、唯一约束和 Authority pin 必须在 PostgreSQL 上验证,不能只依赖 H2。
|
||||
|
||||
只有在以下条件同时满足时才建议开启 `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO=true`:
|
||||
## 10. 部署验收
|
||||
|
||||
- 已确认浏览器在 `skill.xxx.com` 下能稳定带上 SSO Cookie
|
||||
- 失败时 UI 不会卡死或重复重试
|
||||
- 页面只会自动尝试一次
|
||||
- 前端不会因为自动尝试失败而阻断正常密码登录
|
||||
按 Expand → Contract → Profile/Provisioning → Provider Registry 的顺序部署。测试环境
|
||||
验收至少包括:
|
||||
|
||||
如果以上条件不满足,建议只显示一个显式按钮,让用户主动触发。
|
||||
1. 从旧版本升级,Flyway 成功且旧 OAuth/Binding 可继续登录。
|
||||
2. `/api/v1/auth/providers` 旧响应兼容。
|
||||
3. `/api/v1/auth/methods` 只展示 `READY` 且全局开关启用的能力。
|
||||
4. disabled/misconfigured/Authority mismatch Provider 不展示且不连接上游。
|
||||
5. Credential 和 Passive 成功后进入相同 Identity Binding 和 Session 链路。
|
||||
6. PENDING、DISABLED、MERGED、system account 均按核心策略 fail closed。
|
||||
7. 多 Pod + Redis Session 下刷新和切换实例仍保持登录态。
|
||||
8. 回滚到兼容版本时旧 Binding 和本地登录不受损。
|
||||
|
||||
### 6.3 前端禁止事项
|
||||
|
||||
- 不要把密码提交给非 SkillHub 后端地址
|
||||
- 不要在浏览器里解析或操作私有 SSO 内部 Cookie 细节
|
||||
- 不要把 bootstrap 失败当成页面级致命错误
|
||||
|
||||
## 7. Spring Session Redis 相关约束
|
||||
|
||||
当前平台的统一 Web 登录态是 Spring Session。
|
||||
|
||||
后续私有版继续接入时,必须遵守以下规则:
|
||||
|
||||
- 所有成功登录都必须通过 `PlatformSessionService`
|
||||
- 所有 Web 会话都通过 `HttpSession` 持久化
|
||||
- 不要手动维护第二份“私有 SSO session”
|
||||
- 不要在 Redis 中自行定义另一套认证缓存结构来替代 Session
|
||||
|
||||
当前统一服务会做的事:
|
||||
|
||||
- 写入 `platformPrincipal`
|
||||
- 写入 `SPRING_SECURITY_CONTEXT`
|
||||
- 在交互式登录流程中轮换 session id
|
||||
|
||||
## 8. 安全最佳实践
|
||||
|
||||
### 8.1 用户名密码直连场景
|
||||
|
||||
- SkillHub 后端与私有 SSO 之间必须走内网或可信 RPC
|
||||
- 明文密码只允许存在于浏览器提交和后端调用 SSO 的瞬时链路中
|
||||
- 日志、埋点、异常信息中禁止出现密码
|
||||
- 对下游 SSO 调用应设置超时和熔断策略
|
||||
|
||||
### 8.2 Cookie 被动会话场景
|
||||
|
||||
- 必须先确认 Cookie 域、路径、SameSite、Secure 策略能满足 `skill.xxx.com` 使用
|
||||
- bootstrap 接口应保留 CSRF 防护
|
||||
- 失败时只返回认证失败,不泄露过多 Cookie 校验细节
|
||||
- 除非有明确产品要求,否则不要做无感知的全站自动登录 filter
|
||||
|
||||
### 8.3 身份映射场景
|
||||
|
||||
- 只信任稳定 UID,不信任显示名作为主身份依据
|
||||
- 不按 email 合并
|
||||
- 不按 username 合并
|
||||
|
||||
## 9. 建议测试矩阵
|
||||
|
||||
### 9.1 后端单元测试
|
||||
|
||||
- `DirectAuthProvider` 成功认证
|
||||
- `DirectAuthProvider` 认证失败
|
||||
- `PassiveSessionAuthenticator` 在有效 Cookie 下成功返回主体
|
||||
- `PassiveSessionAuthenticator` 在无效 Cookie 下返回空或失败
|
||||
- `PrivateSsoIdentityService` 首次登录自动建号
|
||||
- `PrivateSsoIdentityService` 再次登录复用已有绑定
|
||||
|
||||
### 9.2 后端集成测试
|
||||
|
||||
- `POST /api/v1/auth/direct/login` 在开启配置后能建立 Session
|
||||
- `POST /api/v1/auth/session/bootstrap` 在开启配置后能建立 Session
|
||||
- 成功登录后 `/api/v1/auth/me` 返回正确用户
|
||||
- direct auth 与现有 `/api/v1/auth/local/login` 不互相影响
|
||||
- bootstrap 关闭时仍返回 `403`
|
||||
- direct auth 关闭时仍返回 `403`
|
||||
|
||||
### 9.3 前端测试
|
||||
|
||||
- 未开启运行时开关时,登录页与开源版默认行为一致
|
||||
- 开启 direct auth 后,密码表单请求走 `/api/v1/auth/direct/login`
|
||||
- 开启 bootstrap 按钮后,点击能触发 bootstrap 请求
|
||||
- 自动 bootstrap 失败后,用户仍可正常使用其它登录入口
|
||||
|
||||
### 9.4 手工验收
|
||||
|
||||
- 已登录 SSO 的浏览器中,bootstrap 能成功建立 SkillHub 登录态
|
||||
- 未登录 SSO 的浏览器中,bootstrap 失败但不影响密码登录
|
||||
- direct auth 登录成功后,刷新页面仍保持登录态
|
||||
- 多 Pod 环境下,借助 Spring Session Redis,切换实例后 session 仍有效
|
||||
|
||||
## 10. 推荐开发顺序
|
||||
|
||||
如果后续在私有仓库中真正开始接入,建议按下面顺序推进:
|
||||
|
||||
1. 实现 `PrivateSsoClient`
|
||||
2. 实现 `PrivateSsoIdentityService`
|
||||
3. 实现 `PrivateSsoDirectAuthProvider`
|
||||
4. 先启用 `skillhub.auth.direct.enabled=true`
|
||||
5. 前端接通 direct auth 入口并完成测试
|
||||
6. 再实现 `PrivateSsoPassiveSessionAuthenticator`
|
||||
7. 确认 Cookie 作用域和浏览器行为
|
||||
8. 启用 `session-bootstrap`
|
||||
9. 视需要决定是否开启自动 bootstrap
|
||||
|
||||
## 11. 给 coding agent 的执行指令
|
||||
|
||||
如果后续由 AI 继续在私有仓库上完成接入,建议严格遵守以下执行规则:
|
||||
|
||||
- 先读 [11-auth-extensibility-and-private-sso.md](./11-auth-extensibility-and-private-sso.md) 和本文档
|
||||
- 不要重构现有公共认证主链路,除非发现明确 bug
|
||||
- 私有 SSO 的具体实现优先写成 provider、authenticator、client、identity service
|
||||
- 不要复制 `PlatformSessionService` 逻辑
|
||||
- 不要在多个 controller 或 filter 中重复写 Session 建立代码
|
||||
- 任何新增前端行为都必须保证运行时配置关闭时完全不影响开源版
|
||||
- 所有新增协议和运行时配置必须同步更新文档
|
||||
- 每完成一个阶段都跑后端测试;涉及前端改动时再补跑 `pnpm typecheck` 和 `pnpm build`
|
||||
|
||||
## 12. 完成定义
|
||||
|
||||
当私有版 SSO 接入完成时,应满足以下标准:
|
||||
|
||||
- 开源版默认登录方式仍然不变
|
||||
- 私有版只通过扩展点接入,没有复制一套独立登录架构
|
||||
- direct auth 可用
|
||||
- session bootstrap 可用
|
||||
- 首次 SSO 登录自动建号
|
||||
- 统一使用 Spring Session Redis 承载 Web 登录态
|
||||
- `/api/v1/auth/me`、RBAC、现有业务接口对登录来源无感知
|
||||
- 文档、配置、测试都完整
|
||||
通过后再决定是否合入 `main`;不要在未验证的情况下直接修改生产 Provider 配置。
|
||||
|
|
|
|||
|
|
@ -1846,6 +1846,10 @@ SAML IdP
|
|||
|
||||
`PassiveSessionAuthenticator` 的替代 Adapter 目标输出改为
|
||||
`ProviderAuthenticationResult`,再由核心构造 `IdentityAssertion`;不得返回 Principal。
|
||||
App 层必须先把 `HttpServletRequest` 转换成不可变、仅依赖 JDK 的
|
||||
`PassiveAuthenticationRequest`(method、request URI、query、remote address 和 Header
|
||||
快照),再调用 Adapter。Passive SPI 和 Adapter 不得引用 Servlet、`HttpSession` 或
|
||||
`SecurityContext`;这保证 Adapter 即使被复用也没有建立平台 Session 的能力。
|
||||
|
||||
### 14.7 Kerberos/SPNEGO
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,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.IdentityProviderRegistry;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
|
||||
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
|
||||
import com.iflytek.skillhub.config.AuthSessionBootstrapProperties;
|
||||
import com.iflytek.skillhub.config.DirectAuthProperties;
|
||||
|
|
@ -23,27 +22,21 @@ import org.springframework.stereotype.Service;
|
|||
@Service
|
||||
public class AuthMethodCatalog {
|
||||
|
||||
private final IdentityProviderCatalog identityProviderCatalog;
|
||||
private final IdentityProviderRegistry identityProviderRegistry;
|
||||
private final DirectAuthProperties directAuthProperties;
|
||||
private final AuthSessionBootstrapProperties sessionBootstrapProperties;
|
||||
private final List<DirectAuthProvider> directAuthProviders;
|
||||
private final List<PassiveSessionAuthenticator> passiveSessionAuthenticators;
|
||||
|
||||
public AuthMethodCatalog(IdentityProviderCatalog identityProviderCatalog,
|
||||
public AuthMethodCatalog(IdentityProviderRegistry identityProviderRegistry,
|
||||
DirectAuthProperties directAuthProperties,
|
||||
AuthSessionBootstrapProperties sessionBootstrapProperties,
|
||||
List<DirectAuthProvider> directAuthProviders,
|
||||
List<PassiveSessionAuthenticator> passiveSessionAuthenticators) {
|
||||
this.identityProviderCatalog = identityProviderCatalog;
|
||||
AuthSessionBootstrapProperties sessionBootstrapProperties) {
|
||||
this.identityProviderRegistry = identityProviderRegistry;
|
||||
this.directAuthProperties = directAuthProperties;
|
||||
this.sessionBootstrapProperties = sessionBootstrapProperties;
|
||||
this.directAuthProviders = directAuthProviders;
|
||||
this.passiveSessionAuthenticators = passiveSessionAuthenticators;
|
||||
}
|
||||
|
||||
public List<AuthProviderResponse> listOAuthProviders(String returnTo) {
|
||||
String sanitizedReturnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
|
||||
return new ArrayList<>(identityProviderCatalog.listReadyProviders().stream()
|
||||
return new ArrayList<>(identityProviderRegistry.listReadyProviders().stream()
|
||||
.sorted(Comparator.comparing(IdentityProviderLoginMethod::providerCode))
|
||||
.map(provider -> new AuthProviderResponse(
|
||||
provider.providerCode(),
|
||||
|
|
@ -65,43 +58,66 @@ public class AuthMethodCatalog {
|
|||
"/api/v1/auth/local/login"
|
||||
));
|
||||
|
||||
identityProviderCatalog.listReadyProviders().stream()
|
||||
.sorted(Comparator.comparing(IdentityProviderLoginMethod::providerCode))
|
||||
.forEach(provider -> methods.add(new AuthMethodResponse(
|
||||
"oauth-" + provider.providerCode(),
|
||||
"OAUTH_REDIRECT",
|
||||
provider.providerCode(),
|
||||
provider.displayName(),
|
||||
buildAuthorizationUrl(provider.providerCode(), sanitizedReturnTo)
|
||||
if (directAuthProperties.isEnabled()) {
|
||||
methods.add(new AuthMethodResponse(
|
||||
"direct-local",
|
||||
"DIRECT_PASSWORD",
|
||||
"local",
|
||||
"Local Account",
|
||||
"/api/v1/auth/direct/login"
|
||||
));
|
||||
}
|
||||
|
||||
identityProviderRegistry.listReadyLoginMethods().stream()
|
||||
.filter(this::isMethodEnabled)
|
||||
.sorted(Comparator
|
||||
.comparing(IdentityProviderLoginMethod::providerCode)
|
||||
.thenComparing(IdentityProviderLoginMethod::methodType))
|
||||
.forEach(provider -> methods.add(toResponse(
|
||||
provider,
|
||||
sanitizedReturnTo
|
||||
)));
|
||||
|
||||
if (directAuthProperties.isEnabled()) {
|
||||
directAuthProviders.stream()
|
||||
.sorted(Comparator.comparing(DirectAuthProvider::providerCode))
|
||||
.forEach(provider -> methods.add(new AuthMethodResponse(
|
||||
"direct-" + provider.providerCode(),
|
||||
"DIRECT_PASSWORD",
|
||||
provider.providerCode(),
|
||||
provider.displayName(),
|
||||
"/api/v1/auth/direct/login"
|
||||
)));
|
||||
}
|
||||
|
||||
if (sessionBootstrapProperties.isEnabled()) {
|
||||
passiveSessionAuthenticators.stream()
|
||||
.sorted(Comparator.comparing(PassiveSessionAuthenticator::providerCode))
|
||||
.forEach(provider -> methods.add(new AuthMethodResponse(
|
||||
"bootstrap-" + provider.providerCode(),
|
||||
"SESSION_BOOTSTRAP",
|
||||
provider.providerCode(),
|
||||
provider.displayName(),
|
||||
"/api/v1/auth/session/bootstrap"
|
||||
)));
|
||||
}
|
||||
|
||||
return methods;
|
||||
}
|
||||
|
||||
private boolean isMethodEnabled(IdentityProviderLoginMethod method) {
|
||||
return switch (method.methodType()) {
|
||||
case OAUTH_REDIRECT -> true;
|
||||
case DIRECT_PASSWORD -> directAuthProperties.isEnabled();
|
||||
case SESSION_BOOTSTRAP -> sessionBootstrapProperties.isEnabled();
|
||||
};
|
||||
}
|
||||
|
||||
private AuthMethodResponse toResponse(
|
||||
IdentityProviderLoginMethod method,
|
||||
String returnTo) {
|
||||
String providerCode = method.providerCode();
|
||||
return switch (method.methodType()) {
|
||||
case OAUTH_REDIRECT -> new AuthMethodResponse(
|
||||
"oauth-" + providerCode,
|
||||
IdentityProviderLoginMethodType.OAUTH_REDIRECT.name(),
|
||||
providerCode,
|
||||
method.displayName(),
|
||||
buildAuthorizationUrl(providerCode, returnTo)
|
||||
);
|
||||
case DIRECT_PASSWORD -> new AuthMethodResponse(
|
||||
"direct-" + providerCode,
|
||||
IdentityProviderLoginMethodType.DIRECT_PASSWORD.name(),
|
||||
providerCode,
|
||||
method.displayName(),
|
||||
"/api/v1/auth/direct/login"
|
||||
);
|
||||
case SESSION_BOOTSTRAP -> new AuthMethodResponse(
|
||||
"bootstrap-" + providerCode,
|
||||
IdentityProviderLoginMethodType.SESSION_BOOTSTRAP.name(),
|
||||
providerCode,
|
||||
method.displayName(),
|
||||
"/api/v1/auth/session/bootstrap"
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private String buildAuthorizationUrl(String registrationId, String returnTo) {
|
||||
String baseUrl = "/oauth2/authorization/" + registrationId;
|
||||
if (returnTo == null) {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.auth.direct.DirectAuthProvider;
|
||||
import com.iflytek.skillhub.auth.direct.DirectAuthRequest;
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.local.LocalAuthService;
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.config.DirectAuthProperties;
|
||||
import com.iflytek.skillhub.exception.BadRequestException;
|
||||
import com.iflytek.skillhub.exception.ForbiddenException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
|
|
@ -20,18 +24,20 @@ import org.springframework.stereotype.Service;
|
|||
public class DirectAuthService {
|
||||
|
||||
private final DirectAuthProperties properties;
|
||||
private final Map<String, DirectAuthProvider> providersByCode;
|
||||
private final IdentityProviderRegistry providerRegistry;
|
||||
private final LocalAuthService localAuthService;
|
||||
private final ProviderLoginAppService providerLoginAppService;
|
||||
private final SessionBootstrapService sessionBootstrapService;
|
||||
|
||||
public DirectAuthService(DirectAuthProperties properties,
|
||||
List<DirectAuthProvider> providers,
|
||||
IdentityProviderRegistry providerRegistry,
|
||||
LocalAuthService localAuthService,
|
||||
ProviderLoginAppService providerLoginAppService,
|
||||
SessionBootstrapService sessionBootstrapService) {
|
||||
this.properties = properties;
|
||||
this.providersByCode = providers.stream()
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableMap(
|
||||
DirectAuthProvider::providerCode,
|
||||
Function.identity()
|
||||
));
|
||||
this.providerRegistry = providerRegistry;
|
||||
this.localAuthService = localAuthService;
|
||||
this.providerLoginAppService = providerLoginAppService;
|
||||
this.sessionBootstrapService = sessionBootstrapService;
|
||||
}
|
||||
|
||||
|
|
@ -43,13 +49,54 @@ public class DirectAuthService {
|
|||
throw new ForbiddenException("error.auth.direct.disabled");
|
||||
}
|
||||
|
||||
DirectAuthProvider provider = providersByCode.get(providerCode);
|
||||
if (provider == null) {
|
||||
throw new BadRequestException("error.auth.direct.providerUnsupported", providerCode);
|
||||
PlatformPrincipal principal;
|
||||
if ("local".equals(providerCode)) {
|
||||
principal = localAuthService.login(username, password);
|
||||
} else {
|
||||
IdentityProviderRegistry.CredentialRoute route;
|
||||
try {
|
||||
route = providerRegistry.requireCredentialRoute(providerCode);
|
||||
} catch (IdentityCoreException exception) {
|
||||
if (exception.getReasonCode()
|
||||
== IdentityFailureCode.PROVIDER_DISABLED) {
|
||||
throw new BadRequestException(
|
||||
"error.auth.direct.providerUnsupported",
|
||||
providerCode);
|
||||
}
|
||||
throw new AuthFlowException(
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"error.auth.external.providerUnavailable");
|
||||
}
|
||||
var result = authenticate(
|
||||
route,
|
||||
username,
|
||||
password);
|
||||
if (result == null) {
|
||||
throw new AuthFlowException(
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
"error.auth.external.invalidAssertion");
|
||||
}
|
||||
principal = providerLoginAppService.authenticate(
|
||||
route.provider(),
|
||||
result,
|
||||
request);
|
||||
}
|
||||
|
||||
PlatformPrincipal principal = provider.authenticate(new DirectAuthRequest(username, password));
|
||||
sessionBootstrapService.establishSession(principal, request);
|
||||
return principal;
|
||||
}
|
||||
|
||||
private ProviderAuthenticationResult authenticate(
|
||||
IdentityProviderRegistry.CredentialRoute route,
|
||||
String username,
|
||||
String password) {
|
||||
try {
|
||||
return route.adapter().authenticate(
|
||||
new CredentialAuthenticationRequest(
|
||||
username,
|
||||
password));
|
||||
} catch (ProviderAuthenticationException exception) {
|
||||
throw ProviderAuthenticationFailureMapper.map(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* Maps stable provider failures to public authentication responses without
|
||||
* exposing upstream details.
|
||||
*/
|
||||
final class ProviderAuthenticationFailureMapper {
|
||||
|
||||
private ProviderAuthenticationFailureMapper() {
|
||||
}
|
||||
|
||||
static AuthFlowException map(
|
||||
ProviderAuthenticationException exception) {
|
||||
return switch (exception.getReasonCode()) {
|
||||
case UPSTREAM_INVALID_CREDENTIALS,
|
||||
REPLAY_DETECTED -> failure(
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
"error.auth.external.invalidAssertion");
|
||||
case UPSTREAM_ACCESS_DENIED -> failure(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"error.auth.external.accessDenied");
|
||||
case UPSTREAM_UNAVAILABLE,
|
||||
UPSTREAM_MISCONFIGURED,
|
||||
TLS_VALIDATION_FAILED,
|
||||
UPSTREAM_INVALID_RESPONSE -> failure(
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"error.auth.external.providerUnavailable");
|
||||
};
|
||||
}
|
||||
|
||||
private static AuthFlowException failure(
|
||||
HttpStatus status,
|
||||
String messageCode) {
|
||||
return new AuthFlowException(status, messageCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
|
||||
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.rbac.PlatformPrincipal;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Application boundary shared by credential and passive provider flows.
|
||||
*/
|
||||
@Service
|
||||
class ProviderLoginAppService {
|
||||
|
||||
private static final String REQUEST_ID_MDC_KEY = "requestId";
|
||||
|
||||
private final ExternalIdentityLoginService identityLoginService;
|
||||
|
||||
ProviderLoginAppService(
|
||||
ExternalIdentityLoginService identityLoginService) {
|
||||
this.identityLoginService = identityLoginService;
|
||||
}
|
||||
|
||||
PlatformPrincipal authenticate(
|
||||
ResolvedProviderHandle provider,
|
||||
ProviderAuthenticationResult result,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
IdentityLoginOutcome outcome = identityLoginService.authenticate(
|
||||
provider,
|
||||
result,
|
||||
context(request));
|
||||
if (outcome
|
||||
instanceof IdentityLoginOutcome.Authenticated authenticated) {
|
||||
return authenticated.principal();
|
||||
}
|
||||
if (outcome instanceof IdentityLoginOutcome.PendingApproval) {
|
||||
throw failure(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"error.auth.external.accountPending");
|
||||
}
|
||||
throw failure(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"error.auth.external.linkRequired");
|
||||
} catch (IdentityCoreException exception) {
|
||||
throw mapFailure(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private IdentityLoginContext context(HttpServletRequest request) {
|
||||
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;
|
||||
}
|
||||
|
||||
private AuthFlowException mapFailure(
|
||||
IdentityCoreException exception) {
|
||||
return switch (exception.getReasonCode()) {
|
||||
case PROVIDER_DISABLED -> failure(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"error.auth.external.providerUnavailable");
|
||||
case PROVIDER_AUTHORITY_MISMATCH -> failure(
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"error.auth.external.providerUnavailable");
|
||||
case INVALID_IDENTITY_ASSERTION,
|
||||
IDENTITY_SUBJECT_MISSING,
|
||||
IDENTITY_IDENTIFIER_CONFLICT -> failure(
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
"error.auth.external.invalidAssertion");
|
||||
case ACCESS_DENIED,
|
||||
ACCOUNT_DISABLED,
|
||||
ACCOUNT_MERGED,
|
||||
SYSTEM_ACCOUNT_FORBIDDEN -> failure(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"error.auth.external.accessDenied");
|
||||
case ACCOUNT_PENDING -> failure(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"error.auth.external.accountPending");
|
||||
};
|
||||
}
|
||||
|
||||
private AuthFlowException failure(
|
||||
HttpStatus status,
|
||||
String messageCode) {
|
||||
return new AuthFlowException(status, messageCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator;
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.provider.PassiveAuthenticationRequest;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
||||
import com.iflytek.skillhub.config.AuthSessionBootstrapProperties;
|
||||
|
|
@ -8,9 +14,13 @@ import com.iflytek.skillhub.exception.BadRequestException;
|
|||
import com.iflytek.skillhub.exception.ForbiddenException;
|
||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.Optional;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
|
|
@ -21,18 +31,17 @@ import org.springframework.stereotype.Service;
|
|||
public class SessionBootstrapService {
|
||||
|
||||
private final AuthSessionBootstrapProperties properties;
|
||||
private final Map<String, PassiveSessionAuthenticator> authenticatorsByProvider;
|
||||
private final IdentityProviderRegistry providerRegistry;
|
||||
private final ProviderLoginAppService providerLoginAppService;
|
||||
private final PlatformSessionService platformSessionService;
|
||||
|
||||
public SessionBootstrapService(AuthSessionBootstrapProperties properties,
|
||||
List<PassiveSessionAuthenticator> authenticators,
|
||||
IdentityProviderRegistry providerRegistry,
|
||||
ProviderLoginAppService providerLoginAppService,
|
||||
PlatformSessionService platformSessionService) {
|
||||
this.properties = properties;
|
||||
this.authenticatorsByProvider = authenticators.stream()
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableMap(
|
||||
PassiveSessionAuthenticator::providerCode,
|
||||
Function.identity()
|
||||
));
|
||||
this.providerRegistry = providerRegistry;
|
||||
this.providerLoginAppService = providerLoginAppService;
|
||||
this.platformSessionService = platformSessionService;
|
||||
}
|
||||
|
||||
|
|
@ -41,17 +50,73 @@ public class SessionBootstrapService {
|
|||
throw new ForbiddenException("error.auth.sessionBootstrap.disabled");
|
||||
}
|
||||
|
||||
PassiveSessionAuthenticator authenticator = authenticatorsByProvider.get(providerCode);
|
||||
if (authenticator == null) {
|
||||
throw new BadRequestException("error.auth.sessionBootstrap.providerUnsupported", providerCode);
|
||||
IdentityProviderRegistry.PassiveRoute route;
|
||||
try {
|
||||
route = providerRegistry.requirePassiveRoute(providerCode);
|
||||
} catch (IdentityCoreException exception) {
|
||||
if (exception.getReasonCode()
|
||||
== IdentityFailureCode.PROVIDER_DISABLED) {
|
||||
throw new BadRequestException(
|
||||
"error.auth.sessionBootstrap.providerUnsupported",
|
||||
providerCode);
|
||||
}
|
||||
throw new AuthFlowException(
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"error.auth.external.providerUnavailable");
|
||||
}
|
||||
|
||||
PlatformPrincipal principal = authenticator.authenticate(request)
|
||||
.orElseThrow(() -> new UnauthorizedException("error.auth.sessionBootstrap.notAuthenticated"));
|
||||
var authentication = authenticate(
|
||||
route,
|
||||
toPassiveRequest(request));
|
||||
if (authentication == null) {
|
||||
throw new AuthFlowException(
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
"error.auth.external.invalidAssertion");
|
||||
}
|
||||
var result = authentication
|
||||
.orElseThrow(() -> new UnauthorizedException(
|
||||
"error.auth.sessionBootstrap.notAuthenticated"));
|
||||
PlatformPrincipal principal = providerLoginAppService.authenticate(
|
||||
route.provider(),
|
||||
result,
|
||||
request);
|
||||
platformSessionService.establishSession(principal, request);
|
||||
return principal;
|
||||
}
|
||||
|
||||
private Optional<ProviderAuthenticationResult> authenticate(
|
||||
IdentityProviderRegistry.PassiveRoute route,
|
||||
PassiveAuthenticationRequest request) {
|
||||
try {
|
||||
return route.adapter().authenticate(request);
|
||||
} catch (ProviderAuthenticationException exception) {
|
||||
throw ProviderAuthenticationFailureMapper.map(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private PassiveAuthenticationRequest toPassiveRequest(
|
||||
HttpServletRequest request) {
|
||||
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
if (headerNames != null) {
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String name = headerNames.nextElement();
|
||||
Enumeration<String> values = request.getHeaders(name);
|
||||
headers.put(
|
||||
name,
|
||||
values == null
|
||||
? List.of()
|
||||
: Collections.list(values));
|
||||
}
|
||||
}
|
||||
return new PassiveAuthenticationRequest(
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
request.getQueryString(),
|
||||
request.getRemoteAddr(),
|
||||
headers);
|
||||
}
|
||||
|
||||
public void establishSession(PlatformPrincipal principal, HttpServletRequest request) {
|
||||
platformSessionService.establishSession(principal, request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ error.auth.direct.providerUnsupported=Unsupported direct authentication provider
|
|||
error.auth.sessionBootstrap.disabled=Session bootstrap is disabled
|
||||
error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0}
|
||||
error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found
|
||||
error.auth.external.providerUnavailable=The identity provider is unavailable
|
||||
error.auth.external.invalidAssertion=The external identity assertion was rejected
|
||||
error.auth.external.accessDenied=External account access is denied
|
||||
error.auth.external.accountPending=The external account is pending approval
|
||||
error.auth.external.linkRequired=Additional account verification is required
|
||||
error.auth.merge.temporarilyUnavailable=Account merging is temporarily unavailable while the ownership verification flow is being secured
|
||||
error.badRequest=Invalid request
|
||||
error.forbidden=Forbidden
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0}
|
|||
error.auth.sessionBootstrap.disabled=会话引导能力未启用
|
||||
error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0}
|
||||
error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话
|
||||
error.auth.external.providerUnavailable=身份提供方当前不可用
|
||||
error.auth.external.invalidAssertion=外部身份断言未通过校验
|
||||
error.auth.external.accessDenied=外部账号无权访问
|
||||
error.auth.external.accountPending=外部账号正在等待审批
|
||||
error.auth.external.linkRequired=需要完成额外的账号验证
|
||||
error.auth.merge.temporarilyUnavailable=账号合并功能正在进行安全升级,暂时不可用
|
||||
error.badRequest=请求参数不合法
|
||||
error.forbidden=没有权限执行该操作
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
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;
|
||||
import com.iflytek.skillhub.dto.AuthMethodResponse;
|
||||
import com.iflytek.skillhub.dto.AuthProviderResponse;
|
||||
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 com.iflytek.skillhub.service.AuthMethodCatalog;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -72,15 +73,41 @@ class AuthControllerTest {
|
|||
private LocalCredentialRepository localCredentialRepository;
|
||||
|
||||
@MockBean
|
||||
private IdentityProviderCatalog identityProviderCatalog;
|
||||
private AuthMethodCatalog authMethodCatalog;
|
||||
|
||||
@BeforeEach
|
||||
void setUpReadyIdentityProviders() {
|
||||
given(identityProviderCatalog.listReadyProviders())
|
||||
.willReturn(List.of(new IdentityProviderLoginMethod(
|
||||
given(authMethodCatalog.listOAuthProviders(null))
|
||||
.willReturn(List.of(new AuthProviderResponse(
|
||||
"github",
|
||||
"GitHub"
|
||||
"GitHub",
|
||||
"/oauth2/authorization/github"
|
||||
)));
|
||||
given(authMethodCatalog.listOAuthProviders(
|
||||
"/dashboard/publish"
|
||||
)).willReturn(List.of(new AuthProviderResponse(
|
||||
"github",
|
||||
"GitHub",
|
||||
"/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish"
|
||||
)));
|
||||
given(authMethodCatalog.listMethods(
|
||||
"/dashboard/publish"
|
||||
)).willReturn(List.of(
|
||||
new AuthMethodResponse(
|
||||
"local-password",
|
||||
"PASSWORD",
|
||||
"local",
|
||||
"Local Account",
|
||||
"/api/v1/auth/local/login"
|
||||
),
|
||||
new AuthMethodResponse(
|
||||
"oauth-github",
|
||||
"OAUTH_REDIRECT",
|
||||
"github",
|
||||
"GitHub",
|
||||
"/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish"
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator;
|
||||
import com.iflytek.skillhub.auth.local.LocalCredentialRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.exception.BadRequestException;
|
||||
import com.iflytek.skillhub.service.SessionBootstrapService;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
|
@ -14,15 +15,13 @@ 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.context.TestConfiguration;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
|
@ -52,15 +51,30 @@ class SessionBootstrapControllerTest {
|
|||
@MockBean
|
||||
private LocalCredentialRepository localCredentialRepository;
|
||||
|
||||
@MockBean
|
||||
private SessionBootstrapService sessionBootstrapService;
|
||||
|
||||
@Test
|
||||
void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"sso-user-1",
|
||||
"Private SSO User",
|
||||
null,
|
||||
null,
|
||||
"private-sso",
|
||||
Set.of("USER")
|
||||
);
|
||||
given(sessionBootstrapService.bootstrap(
|
||||
org.mockito.ArgumentMatchers.eq("private-sso"),
|
||||
any()
|
||||
)).willReturn(principal);
|
||||
given(namespaceMemberRepository.findByUserId("sso-user-1")).willReturn(List.of());
|
||||
given(userAccountRepository.findById("sso-user-1"))
|
||||
.willReturn(Optional.of(new UserAccount("sso-user-1", "Private SSO User", null, null)));
|
||||
given(userRoleBindingRepository.findByUserId("sso-user-1")).willReturn(List.of());
|
||||
given(localCredentialRepository.existsByUserId("sso-user-1")).willReturn(false);
|
||||
|
||||
MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/session/bootstrap")
|
||||
mockMvc.perform(post("/api/v1/auth/session/bootstrap")
|
||||
.with(csrf())
|
||||
.contentType("application/json")
|
||||
.content("""
|
||||
|
|
@ -70,21 +84,19 @@ class SessionBootstrapControllerTest {
|
|||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value("sso-user-1"))
|
||||
.andExpect(jsonPath("$.data.displayName").value("Private SSO User"))
|
||||
.andExpect(jsonPath("$.data.canChangePassword").value(false))
|
||||
.andReturn()
|
||||
.getRequest()
|
||||
.getSession(false);
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/me").session(session))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value("sso-user-1"))
|
||||
.andExpect(jsonPath("$.data.oauthProvider").value("private-sso"))
|
||||
.andExpect(jsonPath("$.data.canChangePassword").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionBootstrapShouldRejectUnsupportedProvider() throws Exception {
|
||||
given(sessionBootstrapService.bootstrap(
|
||||
org.mockito.ArgumentMatchers.eq("unknown"),
|
||||
any()
|
||||
)).willThrow(new BadRequestException(
|
||||
"error.auth.sessionBootstrap.providerUnsupported",
|
||||
"unknown"
|
||||
));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/session/bootstrap")
|
||||
.with(csrf())
|
||||
.contentType("application/json")
|
||||
|
|
@ -94,30 +106,4 @@ class SessionBootstrapControllerTest {
|
|||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(400));
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class SessionBootstrapTestConfig {
|
||||
|
||||
@Bean
|
||||
PassiveSessionAuthenticator privateSsoAuthenticator() {
|
||||
return new PassiveSessionAuthenticator() {
|
||||
@Override
|
||||
public String providerCode() {
|
||||
return "private-sso";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PlatformPrincipal> authenticate(jakarta.servlet.http.HttpServletRequest request) {
|
||||
return Optional.of(new PlatformPrincipal(
|
||||
"sso-user-1",
|
||||
"Private SSO User",
|
||||
null,
|
||||
null,
|
||||
"private-sso",
|
||||
Set.of("USER")
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,32 +2,33 @@ package com.iflytek.skillhub.service;
|
|||
|
||||
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.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.IdentityProviderRegistry;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethod;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderLoginMethodType;
|
||||
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;
|
||||
|
||||
class AuthMethodCatalogTest {
|
||||
|
||||
@Test
|
||||
void catalogsOnlyProvidersApprovedByTheIdentityCore() {
|
||||
IdentityProviderCatalog identityProviderCatalog =
|
||||
() -> List.of(new IdentityProviderLoginMethod("valid", "Valid"));
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
when(registry.listReadyProviders()).thenReturn(List.of(
|
||||
new IdentityProviderLoginMethod("valid", "Valid")
|
||||
));
|
||||
when(registry.listReadyLoginMethods()).thenReturn(List.of(
|
||||
new IdentityProviderLoginMethod("valid", "Valid")
|
||||
));
|
||||
|
||||
AuthMethodCatalog catalog = new AuthMethodCatalog(
|
||||
identityProviderCatalog,
|
||||
registry,
|
||||
new DirectAuthProperties(),
|
||||
new AuthSessionBootstrapProperties(),
|
||||
List.of(),
|
||||
List.of()
|
||||
new AuthSessionBootstrapProperties()
|
||||
);
|
||||
|
||||
assertThat(catalog.listOAuthProviders(null))
|
||||
|
|
@ -45,102 +46,69 @@ class AuthMethodCatalogTest {
|
|||
AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties();
|
||||
bootstrapProperties.setEnabled(true);
|
||||
|
||||
DirectAuthProvider directProvider = new DirectAuthProvider() {
|
||||
@Override
|
||||
public String providerCode() {
|
||||
return "private-sso";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "Enterprise Password";
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlatformPrincipal authenticate(DirectAuthRequest request) {
|
||||
throw new UnsupportedOperationException("not used in catalog test");
|
||||
}
|
||||
};
|
||||
|
||||
PassiveSessionAuthenticator bootstrapProvider = new PassiveSessionAuthenticator() {
|
||||
@Override
|
||||
public String providerCode() {
|
||||
return "private-sso";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "Enterprise SSO";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PlatformPrincipal> authenticate(jakarta.servlet.http.HttpServletRequest request) {
|
||||
return Optional.empty();
|
||||
}
|
||||
};
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
when(registry.listReadyProviders()).thenReturn(List.of());
|
||||
when(registry.listReadyLoginMethods()).thenReturn(List.of(
|
||||
new IdentityProviderLoginMethod(
|
||||
"private-sso",
|
||||
"Enterprise Password",
|
||||
IdentityProviderLoginMethodType.DIRECT_PASSWORD
|
||||
),
|
||||
new IdentityProviderLoginMethod(
|
||||
"private-sso",
|
||||
"Enterprise SSO",
|
||||
IdentityProviderLoginMethodType.SESSION_BOOTSTRAP
|
||||
)
|
||||
));
|
||||
|
||||
AuthMethodCatalog catalog = new AuthMethodCatalog(
|
||||
List::of,
|
||||
registry,
|
||||
directAuthProperties,
|
||||
bootstrapProperties,
|
||||
List.of(directProvider),
|
||||
List.of(bootstrapProvider)
|
||||
bootstrapProperties
|
||||
);
|
||||
|
||||
assertThat(catalog.listMethods(null))
|
||||
.extracting(method -> method.id() + ":" + method.displayName())
|
||||
.contains(
|
||||
"local-password:Local Account",
|
||||
"direct-local:Local Account",
|
||||
"direct-private-sso:Enterprise Password",
|
||||
"bootstrap-private-sso:Enterprise SSO"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMethodsShouldFallBackToProviderCodeWhenDisplayNameIsNotOverridden() {
|
||||
DirectAuthProperties directAuthProperties = new DirectAuthProperties();
|
||||
directAuthProperties.setEnabled(true);
|
||||
AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties();
|
||||
bootstrapProperties.setEnabled(true);
|
||||
|
||||
DirectAuthProvider directProvider = new DirectAuthProvider() {
|
||||
@Override
|
||||
public String providerCode() {
|
||||
return "private-sso";
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlatformPrincipal authenticate(DirectAuthRequest request) {
|
||||
return mock(PlatformPrincipal.class);
|
||||
}
|
||||
};
|
||||
|
||||
PassiveSessionAuthenticator bootstrapProvider = new PassiveSessionAuthenticator() {
|
||||
@Override
|
||||
public String providerCode() {
|
||||
return "private-sso";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PlatformPrincipal> authenticate(jakarta.servlet.http.HttpServletRequest request) {
|
||||
return Optional.empty();
|
||||
}
|
||||
};
|
||||
void globalCompatibilityFlagsFilterRegistryCapabilities() {
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
when(registry.listReadyLoginMethods()).thenReturn(List.of(
|
||||
new IdentityProviderLoginMethod(
|
||||
"github",
|
||||
"GitHub",
|
||||
IdentityProviderLoginMethodType.OAUTH_REDIRECT
|
||||
),
|
||||
new IdentityProviderLoginMethod(
|
||||
"private-sso",
|
||||
"Enterprise Password",
|
||||
IdentityProviderLoginMethodType.DIRECT_PASSWORD
|
||||
),
|
||||
new IdentityProviderLoginMethod(
|
||||
"private-sso",
|
||||
"Enterprise SSO",
|
||||
IdentityProviderLoginMethodType.SESSION_BOOTSTRAP
|
||||
)
|
||||
));
|
||||
|
||||
AuthMethodCatalog catalog = new AuthMethodCatalog(
|
||||
List::of,
|
||||
directAuthProperties,
|
||||
bootstrapProperties,
|
||||
List.of(directProvider),
|
||||
List.of(bootstrapProvider)
|
||||
registry,
|
||||
new DirectAuthProperties(),
|
||||
new AuthSessionBootstrapProperties()
|
||||
);
|
||||
|
||||
assertThat(catalog.listMethods(null))
|
||||
.extracting(method -> method.id() + ":" + method.displayName())
|
||||
.contains(
|
||||
"direct-private-sso:private-sso",
|
||||
"bootstrap-private-sso:private-sso"
|
||||
);
|
||||
.extracting(method -> method.id())
|
||||
.containsExactly("local-password", "oauth-github");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,244 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
|
||||
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.local.LocalAuthService;
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.config.DirectAuthProperties;
|
||||
import com.iflytek.skillhub.exception.BadRequestException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
class DirectAuthServiceTest {
|
||||
|
||||
@Test
|
||||
void resolvesReadyRouteBeforeSendingCredentialsToAdapter() {
|
||||
DirectAuthProperties properties = new DirectAuthProperties();
|
||||
properties.setEnabled(true);
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
LocalAuthService localAuth = mock(LocalAuthService.class);
|
||||
ProviderLoginAppService providerLogin =
|
||||
mock(ProviderLoginAppService.class);
|
||||
SessionBootstrapService sessions =
|
||||
mock(SessionBootstrapService.class);
|
||||
CredentialAuthenticationAdapter adapter =
|
||||
mock(CredentialAuthenticationAdapter.class);
|
||||
IdentityProviderRegistry.CredentialRoute route =
|
||||
mock(IdentityProviderRegistry.CredentialRoute.class);
|
||||
ProviderAuthenticationResult result = result();
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"usr_directory",
|
||||
"Directory User",
|
||||
null,
|
||||
null,
|
||||
"directory",
|
||||
Set.of("USER"));
|
||||
HttpServletRequest request =
|
||||
mock(HttpServletRequest.class);
|
||||
|
||||
when(registry.requireCredentialRoute("directory"))
|
||||
.thenReturn(route);
|
||||
when(route.adapter()).thenReturn(adapter);
|
||||
when(adapter.authenticate(any()))
|
||||
.thenReturn(result);
|
||||
when(providerLogin.authenticate(
|
||||
isNull(),
|
||||
org.mockito.ArgumentMatchers.eq(result),
|
||||
org.mockito.ArgumentMatchers.eq(request)))
|
||||
.thenReturn(principal);
|
||||
DirectAuthService service = new DirectAuthService(
|
||||
properties,
|
||||
registry,
|
||||
localAuth,
|
||||
providerLogin,
|
||||
sessions);
|
||||
|
||||
assertThat(service.authenticate(
|
||||
"directory",
|
||||
"alice",
|
||||
"secret",
|
||||
request)).isSameAs(principal);
|
||||
ArgumentCaptor<CredentialAuthenticationRequest> credentials =
|
||||
ArgumentCaptor.forClass(
|
||||
CredentialAuthenticationRequest.class);
|
||||
InOrder order = inOrder(
|
||||
registry,
|
||||
adapter,
|
||||
providerLogin,
|
||||
sessions);
|
||||
order.verify(registry)
|
||||
.requireCredentialRoute("directory");
|
||||
order.verify(adapter)
|
||||
.authenticate(credentials.capture());
|
||||
order.verify(providerLogin).authenticate(
|
||||
isNull(),
|
||||
org.mockito.ArgumentMatchers.eq(result),
|
||||
org.mockito.ArgumentMatchers.eq(request));
|
||||
order.verify(sessions)
|
||||
.establishSession(principal, request);
|
||||
assertThat(credentials.getValue())
|
||||
.isEqualTo(new CredentialAuthenticationRequest(
|
||||
"alice",
|
||||
"secret"));
|
||||
verifyNoInteractions(localAuth);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableProviderCannotReceiveCredentials() {
|
||||
DirectAuthProperties properties = new DirectAuthProperties();
|
||||
properties.setEnabled(true);
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
LocalAuthService localAuth = mock(LocalAuthService.class);
|
||||
ProviderLoginAppService providerLogin =
|
||||
mock(ProviderLoginAppService.class);
|
||||
SessionBootstrapService sessions =
|
||||
mock(SessionBootstrapService.class);
|
||||
CredentialAuthenticationAdapter adapter =
|
||||
mock(CredentialAuthenticationAdapter.class);
|
||||
HttpServletRequest request =
|
||||
mock(HttpServletRequest.class);
|
||||
when(registry.requireCredentialRoute("disabled"))
|
||||
.thenThrow(new IdentityCoreException(
|
||||
IdentityFailureCode.PROVIDER_DISABLED));
|
||||
DirectAuthService service = new DirectAuthService(
|
||||
properties,
|
||||
registry,
|
||||
localAuth,
|
||||
providerLogin,
|
||||
sessions);
|
||||
|
||||
assertThatThrownBy(() -> service.authenticate(
|
||||
"disabled",
|
||||
"alice",
|
||||
"secret",
|
||||
request)).isInstanceOf(BadRequestException.class);
|
||||
verifyNoInteractions(
|
||||
adapter,
|
||||
localAuth,
|
||||
providerLogin,
|
||||
sessions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullAdapterResultIsRejectedBeforeCoreOrSession() {
|
||||
DirectAuthProperties properties = new DirectAuthProperties();
|
||||
properties.setEnabled(true);
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
LocalAuthService localAuth = mock(LocalAuthService.class);
|
||||
ProviderLoginAppService providerLogin =
|
||||
mock(ProviderLoginAppService.class);
|
||||
SessionBootstrapService sessions =
|
||||
mock(SessionBootstrapService.class);
|
||||
CredentialAuthenticationAdapter adapter =
|
||||
mock(CredentialAuthenticationAdapter.class);
|
||||
IdentityProviderRegistry.CredentialRoute route =
|
||||
mock(IdentityProviderRegistry.CredentialRoute.class);
|
||||
HttpServletRequest request =
|
||||
mock(HttpServletRequest.class);
|
||||
when(registry.requireCredentialRoute("broken"))
|
||||
.thenReturn(route);
|
||||
when(route.adapter()).thenReturn(adapter);
|
||||
when(adapter.authenticate(any())).thenReturn(null);
|
||||
DirectAuthService service = new DirectAuthService(
|
||||
properties,
|
||||
registry,
|
||||
localAuth,
|
||||
providerLogin,
|
||||
sessions);
|
||||
|
||||
assertThatThrownBy(() -> service.authenticate(
|
||||
"broken",
|
||||
"alice",
|
||||
"secret",
|
||||
request)).isInstanceOf(AuthFlowException.class)
|
||||
.extracting("status")
|
||||
.isEqualTo(org.springframework.http.HttpStatus.UNAUTHORIZED);
|
||||
verifyNoInteractions(
|
||||
localAuth,
|
||||
providerLogin,
|
||||
sessions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void stableAdapterFailureIsMappedBeforeCoreOrSession() {
|
||||
DirectAuthProperties properties = new DirectAuthProperties();
|
||||
properties.setEnabled(true);
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
LocalAuthService localAuth = mock(LocalAuthService.class);
|
||||
ProviderLoginAppService providerLogin =
|
||||
mock(ProviderLoginAppService.class);
|
||||
SessionBootstrapService sessions =
|
||||
mock(SessionBootstrapService.class);
|
||||
CredentialAuthenticationAdapter adapter =
|
||||
mock(CredentialAuthenticationAdapter.class);
|
||||
IdentityProviderRegistry.CredentialRoute route =
|
||||
mock(IdentityProviderRegistry.CredentialRoute.class);
|
||||
HttpServletRequest request =
|
||||
mock(HttpServletRequest.class);
|
||||
when(registry.requireCredentialRoute("directory"))
|
||||
.thenReturn(route);
|
||||
when(route.adapter()).thenReturn(adapter);
|
||||
when(adapter.authenticate(any())).thenThrow(
|
||||
new ProviderAuthenticationException(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_UNAVAILABLE));
|
||||
DirectAuthService service = new DirectAuthService(
|
||||
properties,
|
||||
registry,
|
||||
localAuth,
|
||||
providerLogin,
|
||||
sessions);
|
||||
|
||||
assertThatThrownBy(() -> service.authenticate(
|
||||
"directory",
|
||||
"alice",
|
||||
"secret",
|
||||
request)).isInstanceOf(AuthFlowException.class)
|
||||
.extracting("status")
|
||||
.isEqualTo(
|
||||
org.springframework.http.HttpStatus
|
||||
.SERVICE_UNAVAILABLE);
|
||||
verifyNoInteractions(localAuth, providerLogin, sessions);
|
||||
}
|
||||
|
||||
private static ProviderAuthenticationResult result() {
|
||||
return new ProviderAuthenticationResult(
|
||||
new SubjectCandidate(
|
||||
"directory_subject",
|
||||
"subject-1"),
|
||||
List.of(),
|
||||
Map.of(),
|
||||
new ProtocolAuthenticationEvidence(
|
||||
"ldap",
|
||||
Instant.parse("2026-07-30T00:00:00Z"),
|
||||
Set.of("password")));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
class ProviderAuthenticationFailureMapperTest {
|
||||
|
||||
@Test
|
||||
void mapsStableProviderFailuresWithoutExposingUpstreamDetails() {
|
||||
assertMapping(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_CREDENTIALS,
|
||||
HttpStatus.UNAUTHORIZED);
|
||||
assertMapping(
|
||||
ProviderAuthenticationFailureCode.REPLAY_DETECTED,
|
||||
HttpStatus.UNAUTHORIZED);
|
||||
assertMapping(
|
||||
ProviderAuthenticationFailureCode.UPSTREAM_ACCESS_DENIED,
|
||||
HttpStatus.FORBIDDEN);
|
||||
assertMapping(
|
||||
ProviderAuthenticationFailureCode.UPSTREAM_UNAVAILABLE,
|
||||
HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertMapping(
|
||||
ProviderAuthenticationFailureCode.UPSTREAM_MISCONFIGURED,
|
||||
HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertMapping(
|
||||
ProviderAuthenticationFailureCode.TLS_VALIDATION_FAILED,
|
||||
HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertMapping(
|
||||
ProviderAuthenticationFailureCode
|
||||
.UPSTREAM_INVALID_RESPONSE,
|
||||
HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
private void assertMapping(
|
||||
ProviderAuthenticationFailureCode reasonCode,
|
||||
HttpStatus status) {
|
||||
AuthFlowException mapped =
|
||||
ProviderAuthenticationFailureMapper.map(
|
||||
new ProviderAuthenticationException(
|
||||
reasonCode,
|
||||
new IllegalStateException(
|
||||
"private upstream detail")));
|
||||
|
||||
assertThat(mapped.getStatus()).isEqualTo(status);
|
||||
assertThat(mapped.getMessage())
|
||||
.doesNotContain("private upstream detail");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.IdentityCoreException;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityFailureCode;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderRegistry;
|
||||
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.PassiveAuthenticationRequest;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationException;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderAuthenticationFailureCode;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.session.PlatformSessionService;
|
||||
import com.iflytek.skillhub.config.AuthSessionBootstrapProperties;
|
||||
import com.iflytek.skillhub.exception.BadRequestException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
class SessionBootstrapServiceTest {
|
||||
|
||||
@Test
|
||||
void resolvesReadyRouteBeforeInvokingAdapterAndCore() {
|
||||
AuthSessionBootstrapProperties properties =
|
||||
new AuthSessionBootstrapProperties();
|
||||
properties.setEnabled(true);
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
ProviderLoginAppService providerLogin =
|
||||
mock(ProviderLoginAppService.class);
|
||||
PlatformSessionService sessions =
|
||||
mock(PlatformSessionService.class);
|
||||
PassiveAuthenticationAdapter adapter =
|
||||
mock(PassiveAuthenticationAdapter.class);
|
||||
IdentityProviderRegistry.PassiveRoute route =
|
||||
mock(IdentityProviderRegistry.PassiveRoute.class);
|
||||
ProviderAuthenticationResult result = result();
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"usr_private",
|
||||
"Private User",
|
||||
null,
|
||||
null,
|
||||
"private-sso",
|
||||
Set.of("USER"));
|
||||
HttpServletRequest request = request();
|
||||
|
||||
when(registry.requirePassiveRoute("private-sso"))
|
||||
.thenReturn(route);
|
||||
when(route.adapter()).thenReturn(adapter);
|
||||
when(adapter.authenticate(any(PassiveAuthenticationRequest.class)))
|
||||
.thenReturn(Optional.of(result));
|
||||
when(providerLogin.authenticate(
|
||||
isNull(),
|
||||
org.mockito.ArgumentMatchers.eq(result),
|
||||
org.mockito.ArgumentMatchers.eq(request)))
|
||||
.thenReturn(principal);
|
||||
|
||||
SessionBootstrapService service =
|
||||
new SessionBootstrapService(
|
||||
properties,
|
||||
registry,
|
||||
providerLogin,
|
||||
sessions);
|
||||
|
||||
assertThat(service.bootstrap("private-sso", request))
|
||||
.isSameAs(principal);
|
||||
InOrder order = inOrder(
|
||||
registry,
|
||||
adapter,
|
||||
providerLogin,
|
||||
sessions);
|
||||
order.verify(registry)
|
||||
.requirePassiveRoute("private-sso");
|
||||
ArgumentCaptor<PassiveAuthenticationRequest> requestCaptor =
|
||||
ArgumentCaptor.forClass(
|
||||
PassiveAuthenticationRequest.class);
|
||||
order.verify(adapter).authenticate(requestCaptor.capture());
|
||||
order.verify(providerLogin).authenticate(
|
||||
isNull(),
|
||||
org.mockito.ArgumentMatchers.eq(result),
|
||||
org.mockito.ArgumentMatchers.eq(request));
|
||||
order.verify(sessions)
|
||||
.establishSession(principal, request);
|
||||
PassiveAuthenticationRequest captured = requestCaptor.getValue();
|
||||
assertThat(captured.method()).isEqualTo("POST");
|
||||
assertThat(captured.requestUri())
|
||||
.isEqualTo("/api/v1/auth/session/bootstrap");
|
||||
assertThat(captured.remoteAddress()).isEqualTo("203.0.113.9");
|
||||
assertThat(captured.firstHeader("x-private-assertion"))
|
||||
.isEqualTo("fixture-assertion");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableProviderCannotInvokeAdapter() {
|
||||
AuthSessionBootstrapProperties properties =
|
||||
new AuthSessionBootstrapProperties();
|
||||
properties.setEnabled(true);
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
ProviderLoginAppService providerLogin =
|
||||
mock(ProviderLoginAppService.class);
|
||||
PlatformSessionService sessions =
|
||||
mock(PlatformSessionService.class);
|
||||
PassiveAuthenticationAdapter adapter =
|
||||
mock(PassiveAuthenticationAdapter.class);
|
||||
HttpServletRequest request = request();
|
||||
when(registry.requirePassiveRoute("disabled"))
|
||||
.thenThrow(new IdentityCoreException(
|
||||
IdentityFailureCode.PROVIDER_DISABLED));
|
||||
SessionBootstrapService service =
|
||||
new SessionBootstrapService(
|
||||
properties,
|
||||
registry,
|
||||
providerLogin,
|
||||
sessions);
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> service.bootstrap("disabled", request))
|
||||
.isInstanceOf(BadRequestException.class);
|
||||
verifyNoInteractions(
|
||||
adapter,
|
||||
providerLogin,
|
||||
sessions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullOptionalFromAdapterIsRejectedBeforeCoreOrSession() {
|
||||
AuthSessionBootstrapProperties properties =
|
||||
new AuthSessionBootstrapProperties();
|
||||
properties.setEnabled(true);
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
ProviderLoginAppService providerLogin =
|
||||
mock(ProviderLoginAppService.class);
|
||||
PlatformSessionService sessions =
|
||||
mock(PlatformSessionService.class);
|
||||
PassiveAuthenticationAdapter adapter =
|
||||
mock(PassiveAuthenticationAdapter.class);
|
||||
IdentityProviderRegistry.PassiveRoute route =
|
||||
mock(IdentityProviderRegistry.PassiveRoute.class);
|
||||
HttpServletRequest request = request();
|
||||
when(registry.requirePassiveRoute("broken"))
|
||||
.thenReturn(route);
|
||||
when(route.adapter()).thenReturn(adapter);
|
||||
when(adapter.authenticate(any(PassiveAuthenticationRequest.class)))
|
||||
.thenReturn(null);
|
||||
SessionBootstrapService service =
|
||||
new SessionBootstrapService(
|
||||
properties,
|
||||
registry,
|
||||
providerLogin,
|
||||
sessions);
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> service.bootstrap("broken", request))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.extracting("status")
|
||||
.isEqualTo(org.springframework.http.HttpStatus.UNAUTHORIZED);
|
||||
verifyNoInteractions(providerLogin, sessions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void stableAdapterFailureIsMappedBeforeCoreOrSession() {
|
||||
AuthSessionBootstrapProperties properties =
|
||||
new AuthSessionBootstrapProperties();
|
||||
properties.setEnabled(true);
|
||||
IdentityProviderRegistry registry =
|
||||
mock(IdentityProviderRegistry.class);
|
||||
ProviderLoginAppService providerLogin =
|
||||
mock(ProviderLoginAppService.class);
|
||||
PlatformSessionService sessions =
|
||||
mock(PlatformSessionService.class);
|
||||
PassiveAuthenticationAdapter adapter =
|
||||
mock(PassiveAuthenticationAdapter.class);
|
||||
IdentityProviderRegistry.PassiveRoute route =
|
||||
mock(IdentityProviderRegistry.PassiveRoute.class);
|
||||
HttpServletRequest request = request();
|
||||
when(registry.requirePassiveRoute("private-sso"))
|
||||
.thenReturn(route);
|
||||
when(route.adapter()).thenReturn(adapter);
|
||||
when(adapter.authenticate(any(PassiveAuthenticationRequest.class)))
|
||||
.thenThrow(
|
||||
new ProviderAuthenticationException(
|
||||
ProviderAuthenticationFailureCode
|
||||
.REPLAY_DETECTED));
|
||||
SessionBootstrapService service =
|
||||
new SessionBootstrapService(
|
||||
properties,
|
||||
registry,
|
||||
providerLogin,
|
||||
sessions);
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> service.bootstrap("private-sso", request))
|
||||
.isInstanceOf(AuthFlowException.class)
|
||||
.extracting("status")
|
||||
.isEqualTo(
|
||||
org.springframework.http.HttpStatus.UNAUTHORIZED);
|
||||
verifyNoInteractions(providerLogin, sessions);
|
||||
}
|
||||
|
||||
private static ProviderAuthenticationResult result() {
|
||||
return new ProviderAuthenticationResult(
|
||||
new SubjectCandidate(
|
||||
"private_subject",
|
||||
"subject-1"),
|
||||
List.of(),
|
||||
Map.of(),
|
||||
new ProtocolAuthenticationEvidence(
|
||||
"private-sso",
|
||||
Instant.parse("2026-07-30T00:00:00Z"),
|
||||
Set.of("sso")));
|
||||
}
|
||||
|
||||
private static MockHttpServletRequest request() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest(
|
||||
"POST",
|
||||
"/api/v1/auth/session/bootstrap");
|
||||
request.setRemoteAddr("203.0.113.9");
|
||||
request.addHeader(
|
||||
"X-Private-Assertion",
|
||||
"fixture-assertion");
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,13 @@
|
|||
package com.iflytek.skillhub.auth.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Optional;
|
||||
import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter;
|
||||
|
||||
/**
|
||||
* Extension point for establishing a SkillHub session from an external passive session,
|
||||
* such as an SSO cookie already present on the request.
|
||||
* Compatibility name for passive request adapters.
|
||||
*
|
||||
* @deprecated implement {@link PassiveAuthenticationAdapter} directly.
|
||||
*/
|
||||
public interface PassiveSessionAuthenticator {
|
||||
|
||||
String providerCode();
|
||||
|
||||
default String displayName() {
|
||||
return providerCode();
|
||||
}
|
||||
|
||||
Optional<PlatformPrincipal> authenticate(HttpServletRequest request);
|
||||
@Deprecated(forRemoval = true)
|
||||
public interface PassiveSessionAuthenticator
|
||||
extends PassiveAuthenticationAdapter {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Authentication bootstrap helpers that restore session state from existing
|
||||
* request context without forcing an explicit login step.
|
||||
* Deprecated compatibility names for passive authentication. New adapters
|
||||
* live in {@code com.iflytek.skillhub.auth.provider}.
|
||||
*/
|
||||
package com.iflytek.skillhub.auth.bootstrap;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
package com.iflytek.skillhub.auth.direct;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter;
|
||||
|
||||
/**
|
||||
* Extension point for username/password style direct authentication sources.
|
||||
* Compatibility name for credential adapters.
|
||||
*
|
||||
* @deprecated implement {@link CredentialAuthenticationAdapter} directly.
|
||||
*/
|
||||
public interface DirectAuthProvider {
|
||||
|
||||
String providerCode();
|
||||
|
||||
default String displayName() {
|
||||
return providerCode();
|
||||
}
|
||||
|
||||
PlatformPrincipal authenticate(DirectAuthRequest request);
|
||||
@Deprecated(forRemoval = true)
|
||||
public interface DirectAuthProvider
|
||||
extends CredentialAuthenticationAdapter {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
package com.iflytek.skillhub.auth.direct;
|
||||
|
||||
/**
|
||||
* @deprecated use
|
||||
* {@link com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest}.
|
||||
*/
|
||||
@Deprecated(forRemoval = true)
|
||||
public record DirectAuthRequest(
|
||||
String username,
|
||||
String password
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
package com.iflytek.skillhub.auth.direct;
|
||||
|
||||
import com.iflytek.skillhub.auth.local.LocalAuthService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Direct-auth provider that delegates username and password verification to the local auth flow.
|
||||
*/
|
||||
@Component
|
||||
public class LocalDirectAuthProvider implements DirectAuthProvider {
|
||||
|
||||
private final LocalAuthService localAuthService;
|
||||
|
||||
public LocalDirectAuthProvider(LocalAuthService localAuthService) {
|
||||
this.localAuthService = localAuthService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String providerCode() {
|
||||
return "local";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
return "Local Account";
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlatformPrincipal authenticate(DirectAuthRequest request) {
|
||||
return localAuthService.login(request.username(), request.password());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Pluggable direct-login abstractions used by local or enterprise login
|
||||
* experiences that bypass OAuth browser redirects.
|
||||
* Deprecated compatibility names for direct authentication. New adapters live
|
||||
* in {@code com.iflytek.skillhub.auth.provider}.
|
||||
*/
|
||||
package com.iflytek.skillhub.auth.direct;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
|
||||
/**
|
||||
* Server-owned static provider configuration consumed by the runtime registry.
|
||||
*/
|
||||
interface ConfiguredProviderDescriptorSource {
|
||||
|
||||
List<ProviderDescriptor> configuredDescriptors();
|
||||
|
||||
String resolveBrowserProviderCode(ClientRegistration registration);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import com.iflytek.skillhub.auth.provider.ProviderCapability;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
|
|
@ -8,14 +9,33 @@ import java.util.Objects;
|
|||
*/
|
||||
public record IdentityProviderLoginMethod(
|
||||
String providerCode,
|
||||
String displayName
|
||||
String displayName,
|
||||
IdentityProviderLoginMethodType methodType
|
||||
) {
|
||||
public IdentityProviderLoginMethod {
|
||||
Objects.requireNonNull(providerCode, "providerCode");
|
||||
Objects.requireNonNull(displayName, "displayName");
|
||||
Objects.requireNonNull(methodType, "methodType");
|
||||
if (providerCode.isBlank() || displayName.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Provider code and display name are required");
|
||||
}
|
||||
}
|
||||
|
||||
public IdentityProviderLoginMethod(
|
||||
String providerCode,
|
||||
String displayName) {
|
||||
this(
|
||||
providerCode,
|
||||
displayName,
|
||||
IdentityProviderLoginMethodType.OAUTH_REDIRECT);
|
||||
}
|
||||
|
||||
public ProviderCapability capability() {
|
||||
return switch (methodType) {
|
||||
case OAUTH_REDIRECT -> ProviderCapability.BROWSER;
|
||||
case DIRECT_PASSWORD -> ProviderCapability.CREDENTIAL;
|
||||
case SESSION_BOOTSTRAP -> ProviderCapability.PASSIVE;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
/**
|
||||
* Presentation-safe interaction type for one provider login method.
|
||||
*/
|
||||
public enum IdentityProviderLoginMethodType {
|
||||
OAUTH_REDIRECT,
|
||||
DIRECT_PASSWORD,
|
||||
SESSION_BOOTSTRAP
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Unified, fail-closed query and routing surface for configured identity
|
||||
* providers.
|
||||
*/
|
||||
public interface IdentityProviderRegistry extends IdentityProviderCatalog {
|
||||
|
||||
List<IdentityProviderLoginMethod> listReadyLoginMethods();
|
||||
|
||||
CredentialRoute requireCredentialRoute(String providerCode);
|
||||
|
||||
PassiveRoute requirePassiveRoute(String providerCode);
|
||||
|
||||
class CredentialRoute {
|
||||
private final ResolvedProviderHandle provider;
|
||||
private final CredentialAuthenticationAdapter adapter;
|
||||
|
||||
public CredentialRoute(
|
||||
ResolvedProviderHandle provider,
|
||||
CredentialAuthenticationAdapter adapter) {
|
||||
this.provider = Objects.requireNonNull(
|
||||
provider,
|
||||
"provider");
|
||||
this.adapter = Objects.requireNonNull(
|
||||
adapter,
|
||||
"adapter");
|
||||
}
|
||||
|
||||
public ResolvedProviderHandle provider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
public CredentialAuthenticationAdapter adapter() {
|
||||
return adapter;
|
||||
}
|
||||
}
|
||||
|
||||
class PassiveRoute {
|
||||
private final ResolvedProviderHandle provider;
|
||||
private final PassiveAuthenticationAdapter adapter;
|
||||
|
||||
public PassiveRoute(
|
||||
ResolvedProviderHandle provider,
|
||||
PassiveAuthenticationAdapter adapter) {
|
||||
this.provider = Objects.requireNonNull(
|
||||
provider,
|
||||
"provider");
|
||||
this.adapter = Objects.requireNonNull(
|
||||
adapter,
|
||||
"adapter");
|
||||
}
|
||||
|
||||
public ResolvedProviderHandle provider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
public PassiveAuthenticationAdapter adapter() {
|
||||
return adapter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,10 @@ package com.iflytek.skillhub.auth.identity;
|
|||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
|
|
@ -21,6 +23,30 @@ public record ProviderAuthenticationResult(
|
|||
) {
|
||||
private static final Pattern ATTRIBUTE_KEY_PATTERN =
|
||||
Pattern.compile("[A-Za-z][A-Za-z0-9_.:-]{0,127}");
|
||||
private static final Set<String> SENSITIVE_ATTRIBUTE_NAMES = Set.of(
|
||||
"token",
|
||||
"password",
|
||||
"passwd",
|
||||
"pwd",
|
||||
"cookie",
|
||||
"ticket",
|
||||
"authorization",
|
||||
"credential",
|
||||
"secret");
|
||||
private static final Set<String> SENSITIVE_COMPACT_NAMES = Set.of(
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"idtoken",
|
||||
"authtoken",
|
||||
"bearertoken",
|
||||
"clientsecret",
|
||||
"apikey",
|
||||
"privatekey",
|
||||
"sessioncookie",
|
||||
"rawresponse",
|
||||
"clientassertion");
|
||||
private static final Pattern LOWER_TO_UPPER_BOUNDARY =
|
||||
Pattern.compile("([a-z0-9])([A-Z])");
|
||||
|
||||
public ProviderAuthenticationResult {
|
||||
Objects.requireNonNull(primarySubject, "primarySubject");
|
||||
|
|
@ -34,6 +60,10 @@ public record ProviderAuthenticationResult(
|
|||
if (key == null || !ATTRIBUTE_KEY_PATTERN.matcher(key).matches()) {
|
||||
throw new IllegalArgumentException("Invalid provider attribute key");
|
||||
}
|
||||
if (isSensitiveAttribute(key)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Sensitive provider attributes are forbidden");
|
||||
}
|
||||
Objects.requireNonNull(values, "Provider attribute values");
|
||||
List<ProviderAttributeValue> copiedValues = List.copyOf(values);
|
||||
if (copiedValues.stream().anyMatch(Objects::isNull)) {
|
||||
|
|
@ -43,4 +73,20 @@ public record ProviderAuthenticationResult(
|
|||
});
|
||||
attributes = Map.copyOf(copied);
|
||||
}
|
||||
|
||||
private static boolean isSensitiveAttribute(String key) {
|
||||
String separated = LOWER_TO_UPPER_BOUNDARY
|
||||
.matcher(key)
|
||||
.replaceAll("$1_$2");
|
||||
String normalized = separated.toLowerCase(Locale.ROOT);
|
||||
for (String segment : normalized.split("[._:-]")) {
|
||||
if (SENSITIVE_ATTRIBUTE_NAMES.contains(segment)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String compact = key.replaceAll("[^A-Za-z0-9]", "")
|
||||
.toLowerCase(Locale.ROOT);
|
||||
return SENSITIVE_COMPACT_NAMES.stream()
|
||||
.anyMatch(compact::contains);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,27 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderInstanceDefinition;
|
||||
import com.iflytek.skillhub.auth.provider.SubjectNormalization;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
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.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Startup projection for the transitional static provider registry.
|
||||
* Runtime provider registry assembled from trusted static browser
|
||||
* configuration and built-in credential/passive adapters.
|
||||
*
|
||||
* <p>Startup reconciliation performs the authority compare-and-set. Every
|
||||
* catalog read then filters the configured descriptor snapshot against the
|
||||
|
|
@ -18,24 +30,36 @@ import org.springframework.stereotype.Component;
|
|||
*/
|
||||
@Component
|
||||
class ReconciledIdentityProviderCatalog
|
||||
implements IdentityProviderCatalog, ApplicationRunner {
|
||||
implements IdentityProviderRegistry,
|
||||
TrustedProviderDescriptorSource,
|
||||
TrustedProviderRouteResolver,
|
||||
ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(
|
||||
ReconciledIdentityProviderCatalog.class);
|
||||
|
||||
private final TrustedProviderDescriptorSource descriptorSource;
|
||||
private final ConfiguredProviderDescriptorSource descriptorSource;
|
||||
private final ProviderAuthorityLockService authorityLockService;
|
||||
private final IdentityBindingPreflightService bindingPreflightService;
|
||||
private final AtomicReference<List<ProviderDescriptor>>
|
||||
configuredProviders = new AtomicReference<>(List.of());
|
||||
private final IdentityProviderPolicyProperties policyProperties;
|
||||
private final List<CredentialAuthenticationAdapter> credentialAdapters;
|
||||
private final List<PassiveAuthenticationAdapter> passiveAdapters;
|
||||
private final AtomicReference<RegistrySnapshot> snapshot =
|
||||
new AtomicReference<>(RegistrySnapshot.empty());
|
||||
|
||||
ReconciledIdentityProviderCatalog(
|
||||
TrustedProviderDescriptorSource descriptorSource,
|
||||
ConfiguredProviderDescriptorSource descriptorSource,
|
||||
ProviderAuthorityLockService authorityLockService,
|
||||
IdentityBindingPreflightService bindingPreflightService) {
|
||||
IdentityBindingPreflightService bindingPreflightService,
|
||||
IdentityProviderPolicyProperties policyProperties,
|
||||
List<CredentialAuthenticationAdapter> credentialAdapters,
|
||||
List<PassiveAuthenticationAdapter> passiveAdapters) {
|
||||
this.descriptorSource = descriptorSource;
|
||||
this.authorityLockService = authorityLockService;
|
||||
this.bindingPreflightService = bindingPreflightService;
|
||||
this.policyProperties = policyProperties;
|
||||
this.credentialAdapters = List.copyOf(credentialAdapters);
|
||||
this.passiveAdapters = List.copyOf(passiveAdapters);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -44,18 +68,14 @@ class ReconciledIdentityProviderCatalog
|
|||
}
|
||||
|
||||
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);
|
||||
RegistrySnapshot reconciled = assembleSnapshot();
|
||||
snapshot.set(reconciled);
|
||||
List<ProviderDescriptor> descriptors = reconciled.descriptors()
|
||||
.values()
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(
|
||||
ProviderDescriptor::providerCode))
|
||||
.toList();
|
||||
reportUnconfiguredBindingProviders(descriptors);
|
||||
for (ProviderDescriptor descriptor : descriptors) {
|
||||
try {
|
||||
|
|
@ -74,6 +94,158 @@ class ReconciledIdentityProviderCatalog
|
|||
}
|
||||
}
|
||||
|
||||
private RegistrySnapshot assembleSnapshot() {
|
||||
Map<String, ProviderDescriptor> descriptors =
|
||||
new LinkedHashMap<>();
|
||||
Set<String> browserProviders = new HashSet<>();
|
||||
Map<String, CredentialRegistration> credentials =
|
||||
new LinkedHashMap<>();
|
||||
Map<String, PassiveRegistration> passives =
|
||||
new LinkedHashMap<>();
|
||||
Set<String> invalidProviders = new HashSet<>();
|
||||
|
||||
try {
|
||||
for (ProviderDescriptor descriptor
|
||||
: descriptorSource.configuredDescriptors()) {
|
||||
registerDescriptor(
|
||||
descriptors,
|
||||
invalidProviders,
|
||||
descriptor);
|
||||
browserProviders.add(descriptor.providerCode());
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
log.error(
|
||||
"Configured browser provider discovery failed");
|
||||
log.debug(
|
||||
"Configured browser provider discovery failure type: {}",
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
for (CredentialAuthenticationAdapter adapter
|
||||
: credentialAdapters) {
|
||||
try {
|
||||
ProviderInstanceDefinition definition =
|
||||
adapter.provider();
|
||||
if (!definition.enabled()) {
|
||||
continue;
|
||||
}
|
||||
ProviderDescriptor descriptor =
|
||||
descriptorFrom(definition);
|
||||
registerDescriptor(
|
||||
descriptors,
|
||||
invalidProviders,
|
||||
descriptor);
|
||||
CredentialRegistration previous = credentials.putIfAbsent(
|
||||
descriptor.providerCode(),
|
||||
new CredentialRegistration(
|
||||
adapter,
|
||||
definition.displayName()));
|
||||
if (previous != null) {
|
||||
invalidProviders.add(descriptor.providerCode());
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn(
|
||||
"Credential provider adapter is hidden because its trusted definition is invalid");
|
||||
log.debug(
|
||||
"Credential provider definition failure type: {}",
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
for (PassiveAuthenticationAdapter adapter : passiveAdapters) {
|
||||
try {
|
||||
ProviderInstanceDefinition definition =
|
||||
adapter.provider();
|
||||
if (!definition.enabled()) {
|
||||
continue;
|
||||
}
|
||||
ProviderDescriptor descriptor =
|
||||
descriptorFrom(definition);
|
||||
registerDescriptor(
|
||||
descriptors,
|
||||
invalidProviders,
|
||||
descriptor);
|
||||
PassiveRegistration previous = passives.putIfAbsent(
|
||||
descriptor.providerCode(),
|
||||
new PassiveRegistration(
|
||||
adapter,
|
||||
definition.displayName()));
|
||||
if (previous != null) {
|
||||
invalidProviders.add(descriptor.providerCode());
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn(
|
||||
"Passive provider adapter is hidden because its trusted definition is invalid");
|
||||
log.debug(
|
||||
"Passive provider definition failure type: {}",
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
for (String providerCode : invalidProviders) {
|
||||
descriptors.remove(providerCode);
|
||||
browserProviders.remove(providerCode);
|
||||
credentials.remove(providerCode);
|
||||
passives.remove(providerCode);
|
||||
log.error(
|
||||
"Identity provider '{}' is hidden because trusted definitions or capabilities conflict",
|
||||
providerCode);
|
||||
}
|
||||
|
||||
return new RegistrySnapshot(
|
||||
Map.copyOf(descriptors),
|
||||
Set.copyOf(browserProviders),
|
||||
Map.copyOf(credentials),
|
||||
Map.copyOf(passives));
|
||||
}
|
||||
|
||||
private void registerDescriptor(
|
||||
Map<String, ProviderDescriptor> descriptors,
|
||||
Set<String> invalidProviders,
|
||||
ProviderDescriptor descriptor) {
|
||||
ProviderDescriptor existing = descriptors.putIfAbsent(
|
||||
descriptor.providerCode(),
|
||||
descriptor);
|
||||
if (existing != null && !existing.equals(descriptor)) {
|
||||
invalidProviders.add(descriptor.providerCode());
|
||||
}
|
||||
}
|
||||
|
||||
private ProviderDescriptor descriptorFrom(
|
||||
ProviderInstanceDefinition definition) {
|
||||
Map<String, SubjectCanonicalizer> canonicalizers =
|
||||
new LinkedHashMap<>();
|
||||
definition.subjectNormalizations().forEach(
|
||||
(subjectType, normalization) ->
|
||||
canonicalizers.put(
|
||||
subjectType,
|
||||
canonicalizer(normalization)));
|
||||
IdentityProviderPolicyProperties.ProviderIdentityPolicy policy =
|
||||
policyProperties.resolve(definition.providerCode());
|
||||
return new ProviderDescriptor(
|
||||
definition.providerCode(),
|
||||
definition.protocol(),
|
||||
definition.canonicalAuthority(),
|
||||
definition.displayName(),
|
||||
definition.primarySubjectType(),
|
||||
definition.legacyPrimarySubjectType(),
|
||||
canonicalizers,
|
||||
definition.displayNameAttributes(),
|
||||
definition.emailAttributes(),
|
||||
definition.avatarAttributes(),
|
||||
definition.emailAssuranceLimit(),
|
||||
policy.provisioningMode(),
|
||||
policy.profileSyncPolicy());
|
||||
}
|
||||
|
||||
private SubjectCanonicalizer canonicalizer(
|
||||
SubjectNormalization normalization) {
|
||||
return switch (normalization) {
|
||||
case EXACT -> SubjectCanonicalizer.EXACT;
|
||||
case DECIMAL -> SubjectCanonicalizer.DECIMAL;
|
||||
};
|
||||
}
|
||||
|
||||
private void reportUnconfiguredBindingProviders(
|
||||
List<ProviderDescriptor> descriptors) {
|
||||
try {
|
||||
|
|
@ -93,14 +265,159 @@ class ReconciledIdentityProviderCatalog
|
|||
|
||||
@Override
|
||||
public List<IdentityProviderLoginMethod> listReadyProviders() {
|
||||
return configuredProviders.get().stream()
|
||||
RegistrySnapshot current = snapshot.get();
|
||||
return current.descriptors().values().stream()
|
||||
.sorted(Comparator.comparing(
|
||||
ProviderDescriptor::providerCode))
|
||||
.filter(descriptor -> current.browserProviders()
|
||||
.contains(descriptor.providerCode()))
|
||||
.filter(this::isCurrentlyReady)
|
||||
.map(descriptor -> new IdentityProviderLoginMethod(
|
||||
.map(descriptor -> loginMethod(
|
||||
descriptor.providerCode(),
|
||||
descriptor.displayName()))
|
||||
descriptor.displayName(),
|
||||
IdentityProviderLoginMethodType.OAUTH_REDIRECT))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IdentityProviderLoginMethod> listReadyLoginMethods() {
|
||||
RegistrySnapshot current = snapshot.get();
|
||||
List<IdentityProviderLoginMethod> methods = new ArrayList<>();
|
||||
List<ProviderDescriptor> descriptors = current.descriptors()
|
||||
.values()
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(
|
||||
ProviderDescriptor::providerCode))
|
||||
.toList();
|
||||
for (ProviderDescriptor descriptor : descriptors) {
|
||||
if (!isCurrentlyReady(descriptor)) {
|
||||
continue;
|
||||
}
|
||||
String providerCode = descriptor.providerCode();
|
||||
if (current.browserProviders().contains(providerCode)) {
|
||||
methods.add(loginMethod(
|
||||
providerCode,
|
||||
descriptor.displayName(),
|
||||
IdentityProviderLoginMethodType.OAUTH_REDIRECT));
|
||||
}
|
||||
CredentialRegistration credential =
|
||||
current.credentials().get(providerCode);
|
||||
if (credential != null) {
|
||||
methods.add(loginMethod(
|
||||
providerCode,
|
||||
credential.displayName(),
|
||||
IdentityProviderLoginMethodType.DIRECT_PASSWORD));
|
||||
}
|
||||
PassiveRegistration passive =
|
||||
current.passives().get(providerCode);
|
||||
if (passive != null) {
|
||||
methods.add(loginMethod(
|
||||
providerCode,
|
||||
passive.displayName(),
|
||||
IdentityProviderLoginMethodType.SESSION_BOOTSTRAP));
|
||||
}
|
||||
}
|
||||
return List.copyOf(methods);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CredentialRoute requireCredentialRoute(
|
||||
String providerCode) {
|
||||
RegistrySnapshot current = snapshot.get();
|
||||
ProviderDescriptor descriptor =
|
||||
requireReadyDescriptor(current, providerCode);
|
||||
CredentialRegistration registration =
|
||||
current.credentials().get(providerCode);
|
||||
if (registration == null) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
return new CredentialRoute(
|
||||
new DefaultResolvedProviderHandle(
|
||||
descriptor.providerCode()),
|
||||
registration.adapter());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PassiveRoute requirePassiveRoute(String providerCode) {
|
||||
RegistrySnapshot current = snapshot.get();
|
||||
ProviderDescriptor descriptor =
|
||||
requireReadyDescriptor(current, providerCode);
|
||||
PassiveRegistration registration =
|
||||
current.passives().get(providerCode);
|
||||
if (registration == null) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
return new PassiveRoute(
|
||||
new DefaultResolvedProviderHandle(
|
||||
descriptor.providerCode()),
|
||||
registration.adapter());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResolvedProviderHandle resolve(
|
||||
ClientRegistration registration) {
|
||||
String providerCode =
|
||||
descriptorSource.resolveBrowserProviderCode(registration);
|
||||
RegistrySnapshot current = snapshot.get();
|
||||
if (!current.browserProviders().contains(providerCode)) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
ProviderDescriptor descriptor =
|
||||
requireReadyDescriptor(current, providerCode);
|
||||
return new DefaultResolvedProviderHandle(
|
||||
descriptor.providerCode());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderDescriptor require(ResolvedProviderHandle provider) {
|
||||
if (!(provider instanceof DefaultResolvedProviderHandle handle)) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
ProviderDescriptor descriptor = snapshot.get()
|
||||
.descriptors()
|
||||
.get(handle.providerCode());
|
||||
if (descriptor == null) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderDescriptor> enabledDescriptors() {
|
||||
return snapshot.get().descriptors().values().stream()
|
||||
.sorted(Comparator.comparing(
|
||||
ProviderDescriptor::providerCode))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private ProviderDescriptor requireReadyDescriptor(
|
||||
RegistrySnapshot current,
|
||||
String providerCode) {
|
||||
if (providerCode == null) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
ProviderDescriptor descriptor =
|
||||
current.descriptors().get(providerCode);
|
||||
if (descriptor == null) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
authorityLockService.requirePinnedAuthority(descriptor);
|
||||
if (!authorityLockService.isReady(descriptor)) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private IdentityProviderLoginMethod loginMethod(
|
||||
String providerCode,
|
||||
String displayName,
|
||||
IdentityProviderLoginMethodType methodType) {
|
||||
return new IdentityProviderLoginMethod(
|
||||
providerCode,
|
||||
displayName,
|
||||
methodType);
|
||||
}
|
||||
|
||||
private boolean isCurrentlyReady(ProviderDescriptor descriptor) {
|
||||
try {
|
||||
authorityLockService.requirePinnedAuthority(descriptor);
|
||||
|
|
@ -113,4 +430,34 @@ class ReconciledIdentityProviderCatalog
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private IdentityCoreException providerDisabled() {
|
||||
return new IdentityCoreException(
|
||||
IdentityFailureCode.PROVIDER_DISABLED);
|
||||
}
|
||||
|
||||
private record CredentialRegistration(
|
||||
CredentialAuthenticationAdapter adapter,
|
||||
String displayName) {
|
||||
}
|
||||
|
||||
private record PassiveRegistration(
|
||||
PassiveAuthenticationAdapter adapter,
|
||||
String displayName) {
|
||||
}
|
||||
|
||||
private record RegistrySnapshot(
|
||||
Map<String, ProviderDescriptor> descriptors,
|
||||
Set<String> browserProviders,
|
||||
Map<String, CredentialRegistration> credentials,
|
||||
Map<String, PassiveRegistration> passives
|
||||
) {
|
||||
private static RegistrySnapshot empty() {
|
||||
return new RegistrySnapshot(
|
||||
Map.of(),
|
||||
Set.of(),
|
||||
Map.of(),
|
||||
Map.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ import org.springframework.stereotype.Component;
|
|||
*/
|
||||
@Component
|
||||
class StaticTrustedProviderDescriptorSource
|
||||
implements TrustedProviderDescriptorSource,
|
||||
TrustedProviderRouteResolver {
|
||||
implements ConfiguredProviderDescriptorSource {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(
|
||||
StaticTrustedProviderDescriptorSource.class);
|
||||
|
|
@ -93,7 +92,7 @@ class StaticTrustedProviderDescriptorSource
|
|||
}
|
||||
|
||||
@Override
|
||||
public ResolvedProviderHandle resolve(
|
||||
public String resolveBrowserProviderCode(
|
||||
ClientRegistration registration) {
|
||||
if (registration == null) {
|
||||
throw providerDisabled();
|
||||
|
|
@ -107,24 +106,11 @@ class StaticTrustedProviderDescriptorSource
|
|||
if (trusted == null || trusted != registration) {
|
||||
throw providerDisabled();
|
||||
}
|
||||
return new DefaultResolvedProviderHandle(providerCode);
|
||||
return 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() {
|
||||
public List<ProviderDescriptor> configuredDescriptors() {
|
||||
return descriptors.values().stream()
|
||||
.sorted(Comparator.comparing(
|
||||
ProviderDescriptor::providerCode))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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.ResolvedProviderHandle;
|
||||
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import java.util.HashMap;
|
||||
|
|
@ -62,6 +63,9 @@ public class CustomOidcUserService implements OAuth2UserService<OidcUserRequest,
|
|||
public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
|
||||
String registrationId = request.getClientRegistration().getRegistrationId();
|
||||
log.debug("OIDC login initiated for registration '{}'", registrationId);
|
||||
ResolvedProviderHandle provider =
|
||||
oauthLoginFlowService.requireReadyProvider(
|
||||
request.getClientRegistration());
|
||||
var loginContext = contextResolver.current();
|
||||
|
||||
OidcUser upstreamUser = delegate.loadUser(request);
|
||||
|
|
@ -75,7 +79,7 @@ public class CustomOidcUserService implements OAuth2UserService<OidcUserRequest,
|
|||
PlatformPrincipal principal;
|
||||
try {
|
||||
principal = oauthLoginFlowService.authenticate(
|
||||
request.getClientRegistration(),
|
||||
provider,
|
||||
result,
|
||||
loginContext);
|
||||
} catch (OAuth2AuthenticationException e) {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
|
|||
|
||||
boolean emailVerified = isConfirmed(attrs.get("confirmed_at"));
|
||||
|
||||
log.debug("Initial email from GitLab: {}, verified: {}", email, emailVerified);
|
||||
log.debug(
|
||||
"GitLab email claim present: {}, verified: {}",
|
||||
email != null,
|
||||
emailVerified);
|
||||
|
||||
// If email is not verified or not present, try to fetch from emails API
|
||||
if (email == null || !emailVerified) {
|
||||
|
|
@ -66,7 +69,7 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
|
|||
if (primaryEmail != null) {
|
||||
email = primaryEmail.email();
|
||||
emailVerified = true;
|
||||
log.debug("Found verified email from GitLab API: {}", email);
|
||||
log.debug("Found a verified email from GitLab API");
|
||||
} else {
|
||||
log.debug("No verified email found from GitLab emails API");
|
||||
}
|
||||
|
|
@ -79,8 +82,11 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
|
|||
}
|
||||
|
||||
String subject = String.valueOf(attrs.get("id"));
|
||||
log.info("GitLab OAuth claims extracted - subject: {}, username: {}, email: {}, emailVerified: {}",
|
||||
subject, username, email, emailVerified);
|
||||
log.debug(
|
||||
"GitLab OAuth claims extracted: usernamePresent={}, emailPresent={}, emailVerified={}",
|
||||
username != null,
|
||||
email != null,
|
||||
emailVerified);
|
||||
|
||||
Map<String, List<ProviderAttributeValue>> attributes =
|
||||
new LinkedHashMap<>();
|
||||
|
|
@ -149,7 +155,10 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
|
|||
.findFirst()
|
||||
.orElse(null);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch emails from GitLab API: {}", e.getMessage());
|
||||
log.warn("Failed to fetch verified email from GitLab API");
|
||||
log.debug(
|
||||
"GitLab email lookup failure type: {}",
|
||||
e.getClass().getSimpleName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
/**
|
||||
* Verified Spring Security OAuth exchange consumed by an OAuth claims adapter.
|
||||
*/
|
||||
public record OAuthAuthenticationExchange(
|
||||
OAuth2UserRequest request,
|
||||
OAuth2User user
|
||||
) {
|
||||
public OAuthAuthenticationExchange {
|
||||
Objects.requireNonNull(request, "request");
|
||||
Objects.requireNonNull(user, "user");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,23 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.provider.BrowserAuthenticationAdapter;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
/**
|
||||
* Strategy interface for converting provider-specific OAuth user payloads into normalized claims.
|
||||
*/
|
||||
public interface OAuthClaimsExtractor {
|
||||
public interface OAuthClaimsExtractor
|
||||
extends BrowserAuthenticationAdapter<OAuthAuthenticationExchange> {
|
||||
String getProvider();
|
||||
ProviderAuthenticationResult extract(
|
||||
OAuth2UserRequest request,
|
||||
OAuth2User oAuth2User);
|
||||
|
||||
@Override
|
||||
default ProviderAuthenticationResult authenticate(
|
||||
OAuthAuthenticationExchange exchange) {
|
||||
return extract(exchange.request(), exchange.user());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,15 @@ import java.net.URLEncoder;
|
|||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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.client.userinfo.OAuth2UserService;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
|
@ -34,26 +37,40 @@ import org.springframework.stereotype.Service;
|
|||
@Service
|
||||
public class OAuthLoginFlowService {
|
||||
|
||||
private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
|
||||
private final OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate;
|
||||
private final Map<String, OAuthClaimsExtractor> extractors;
|
||||
private final TrustedProviderRouteResolver providerRouteResolver;
|
||||
private final ExternalIdentityLoginService identityLoginService;
|
||||
|
||||
@Autowired
|
||||
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
|
||||
TrustedProviderRouteResolver providerRouteResolver,
|
||||
ExternalIdentityLoginService identityLoginService) {
|
||||
this(
|
||||
extractorList,
|
||||
providerRouteResolver,
|
||||
identityLoginService,
|
||||
new DefaultOAuth2UserService());
|
||||
}
|
||||
|
||||
OAuthLoginFlowService(
|
||||
List<OAuthClaimsExtractor> extractorList,
|
||||
TrustedProviderRouteResolver providerRouteResolver,
|
||||
ExternalIdentityLoginService identityLoginService,
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate) {
|
||||
this.extractors = extractorList.stream()
|
||||
.collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity()));
|
||||
.collect(Collectors.toMap(
|
||||
OAuthClaimsExtractor::getProvider,
|
||||
Function.identity()));
|
||||
this.providerRouteResolver = providerRouteResolver;
|
||||
this.identityLoginService = identityLoginService;
|
||||
this.delegate = Objects.requireNonNull(delegate, "delegate");
|
||||
}
|
||||
|
||||
public AuthenticatedLoginContext loadLoginContext(
|
||||
OAuth2UserRequest request,
|
||||
IdentityLoginContext context) {
|
||||
OAuth2User upstreamUser = delegate.loadUser(request);
|
||||
String registrationId = request.getClientRegistration().getRegistrationId();
|
||||
|
||||
OAuthClaimsExtractor extractor = extractors.get(registrationId);
|
||||
if (extractor == null) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
|
|
@ -61,22 +78,44 @@ public class OAuthLoginFlowService {
|
|||
);
|
||||
}
|
||||
|
||||
ResolvedProviderHandle provider =
|
||||
requireReadyProvider(request.getClientRegistration());
|
||||
OAuth2User upstreamUser = delegate.loadUser(request);
|
||||
ProviderAuthenticationResult result =
|
||||
extractor.extract(request, upstreamUser);
|
||||
extractor.authenticate(new OAuthAuthenticationExchange(
|
||||
request,
|
||||
upstreamUser));
|
||||
PlatformPrincipal principal = authenticate(
|
||||
request.getClientRegistration(),
|
||||
provider,
|
||||
result,
|
||||
context);
|
||||
return new AuthenticatedLoginContext(upstreamUser, principal);
|
||||
}
|
||||
|
||||
public ResolvedProviderHandle requireReadyProvider(
|
||||
ClientRegistration registration) {
|
||||
try {
|
||||
return providerRouteResolver.resolve(registration);
|
||||
} catch (IdentityCoreException exception) {
|
||||
throw mapIdentityFailure(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public PlatformPrincipal authenticate(
|
||||
ClientRegistration registration,
|
||||
ProviderAuthenticationResult result,
|
||||
IdentityLoginContext context) {
|
||||
return authenticate(
|
||||
requireReadyProvider(registration),
|
||||
result,
|
||||
context);
|
||||
}
|
||||
|
||||
PlatformPrincipal authenticate(
|
||||
ResolvedProviderHandle provider,
|
||||
ProviderAuthenticationResult result,
|
||||
IdentityLoginContext context) {
|
||||
try {
|
||||
ResolvedProviderHandle provider =
|
||||
providerRouteResolver.resolve(registration);
|
||||
IdentityLoginOutcome outcome = identityLoginService.authenticate(
|
||||
provider,
|
||||
result,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
|
||||
/**
|
||||
* Converts one protocol-specific, already verified browser exchange into
|
||||
* provider facts. The transport flow retains ownership of redirects,
|
||||
* callbacks, state and session handling.
|
||||
*
|
||||
* @param <T> protocol-specific verified exchange type
|
||||
*/
|
||||
public interface BrowserAuthenticationAdapter<T> {
|
||||
|
||||
/**
|
||||
* @throws ProviderAuthenticationException when the verified exchange
|
||||
* cannot be accepted or its upstream is unavailable
|
||||
*/
|
||||
ProviderAuthenticationResult authenticate(T exchange);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
|
||||
/**
|
||||
* Active credential authentication capability, for example LDAP bind.
|
||||
*/
|
||||
public interface CredentialAuthenticationAdapter {
|
||||
|
||||
ProviderInstanceDefinition provider();
|
||||
|
||||
/**
|
||||
* @throws ProviderAuthenticationException for classified authentication
|
||||
* or upstream failures
|
||||
*/
|
||||
ProviderAuthenticationResult authenticate(
|
||||
CredentialAuthenticationRequest request);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
/**
|
||||
* Credentials supplied to an active credential adapter.
|
||||
*/
|
||||
public record CredentialAuthenticationRequest(
|
||||
String username,
|
||||
String password
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Passive request authentication capability, for example a trusted gateway
|
||||
* assertion. Implementations receive an immutable request snapshot without
|
||||
* access to the servlet session or security context.
|
||||
*/
|
||||
public interface PassiveAuthenticationAdapter {
|
||||
|
||||
ProviderInstanceDefinition provider();
|
||||
|
||||
/**
|
||||
* @return an authenticated identity, or empty when the request carries no
|
||||
* external authentication
|
||||
* @throws ProviderAuthenticationException when an assertion is present
|
||||
* but invalid, replayed, or cannot be verified
|
||||
*/
|
||||
Optional<ProviderAuthenticationResult> authenticate(
|
||||
PassiveAuthenticationRequest request);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Immutable, servlet-independent input for passive authentication adapters.
|
||||
*
|
||||
* <p>Header and query values can contain credentials and must not be logged or
|
||||
* retained. The application layer creates this snapshot without exposing the
|
||||
* HTTP session or Spring Security context to an adapter.</p>
|
||||
*/
|
||||
public record PassiveAuthenticationRequest(
|
||||
String method,
|
||||
String requestUri,
|
||||
String queryString,
|
||||
String remoteAddress,
|
||||
Map<String, List<String>> headers
|
||||
) {
|
||||
|
||||
public PassiveAuthenticationRequest {
|
||||
Objects.requireNonNull(method, "method");
|
||||
Objects.requireNonNull(requestUri, "requestUri");
|
||||
Objects.requireNonNull(headers, "headers");
|
||||
if (method.isBlank()) {
|
||||
throw new IllegalArgumentException("HTTP method is required");
|
||||
}
|
||||
if (requestUri.isBlank()) {
|
||||
throw new IllegalArgumentException("Request URI is required");
|
||||
}
|
||||
|
||||
method = method.toUpperCase(Locale.ROOT);
|
||||
LinkedHashMap<String, List<String>> copied =
|
||||
new LinkedHashMap<>();
|
||||
headers.forEach((name, values) -> {
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"HTTP header name is required");
|
||||
}
|
||||
Objects.requireNonNull(values, "HTTP header values");
|
||||
List<String> copiedValues = List.copyOf(values);
|
||||
String normalizedName = name.toLowerCase(Locale.ROOT);
|
||||
copied.merge(
|
||||
normalizedName,
|
||||
copiedValues,
|
||||
PassiveAuthenticationRequest::merge);
|
||||
});
|
||||
headers = Collections.unmodifiableMap(copied);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an immutable, case-insensitive header lookup result.
|
||||
*/
|
||||
public List<String> headerValues(String name) {
|
||||
Objects.requireNonNull(name, "name");
|
||||
return headers.getOrDefault(
|
||||
name.toLowerCase(Locale.ROOT),
|
||||
List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first header value, or {@code null} when absent.
|
||||
*/
|
||||
public String firstHeader(String name) {
|
||||
List<String> values = headerValues(name);
|
||||
return values.isEmpty() ? null : values.getFirst();
|
||||
}
|
||||
|
||||
private static List<String> merge(
|
||||
List<String> existing,
|
||||
List<String> additional) {
|
||||
ArrayList<String> merged = new ArrayList<>(
|
||||
existing.size() + additional.size());
|
||||
merged.addAll(existing);
|
||||
merged.addAll(additional);
|
||||
return List.copyOf(merged);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Authentication-adapter failure carrying only a stable reason code.
|
||||
*
|
||||
* <p>Adapters must not put credentials, tokens, tickets, cookies, upstream
|
||||
* payloads or user identifiers in the exception message.</p>
|
||||
*/
|
||||
public final class ProviderAuthenticationException
|
||||
extends RuntimeException {
|
||||
|
||||
private final ProviderAuthenticationFailureCode reasonCode;
|
||||
|
||||
public ProviderAuthenticationException(
|
||||
ProviderAuthenticationFailureCode reasonCode) {
|
||||
super(Objects.requireNonNull(reasonCode, "reasonCode").name());
|
||||
this.reasonCode = reasonCode;
|
||||
}
|
||||
|
||||
public ProviderAuthenticationException(
|
||||
ProviderAuthenticationFailureCode reasonCode,
|
||||
Throwable cause) {
|
||||
super(
|
||||
Objects.requireNonNull(reasonCode, "reasonCode").name(),
|
||||
cause);
|
||||
this.reasonCode = reasonCode;
|
||||
}
|
||||
|
||||
public ProviderAuthenticationFailureCode getReasonCode() {
|
||||
return reasonCode;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
/**
|
||||
* Stable, non-sensitive failure classification reported by an authentication
|
||||
* adapter.
|
||||
*/
|
||||
public enum ProviderAuthenticationFailureCode {
|
||||
UPSTREAM_INVALID_CREDENTIALS,
|
||||
UPSTREAM_ACCESS_DENIED,
|
||||
UPSTREAM_UNAVAILABLE,
|
||||
UPSTREAM_MISCONFIGURED,
|
||||
TLS_VALIDATION_FAILED,
|
||||
UPSTREAM_INVALID_RESPONSE,
|
||||
REPLAY_DETECTED
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
/**
|
||||
* Interaction capabilities negotiated independently for one provider instance.
|
||||
*/
|
||||
public enum ProviderCapability {
|
||||
BROWSER,
|
||||
CREDENTIAL,
|
||||
PASSIVE
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.EmailAssurance;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Server-owned identity-domain metadata contributed by a trusted adapter.
|
||||
*
|
||||
* <p>The definition is read during registry reconciliation. It must not be
|
||||
* assembled from an authentication response or other caller-controlled data.</p>
|
||||
*/
|
||||
public record ProviderInstanceDefinition(
|
||||
String providerCode,
|
||||
String protocol,
|
||||
String canonicalAuthority,
|
||||
String displayName,
|
||||
String primarySubjectType,
|
||||
String legacyPrimarySubjectType,
|
||||
Map<String, SubjectNormalization> subjectNormalizations,
|
||||
List<String> displayNameAttributes,
|
||||
List<String> emailAttributes,
|
||||
List<String> avatarAttributes,
|
||||
EmailAssurance emailAssuranceLimit,
|
||||
boolean enabled
|
||||
) {
|
||||
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}");
|
||||
private static final Pattern SUBJECT_TYPE_PATTERN =
|
||||
Pattern.compile("[a-z][a-z0-9_]{0,63}");
|
||||
private static final Pattern ATTRIBUTE_KEY_PATTERN =
|
||||
Pattern.compile("[A-Za-z][A-Za-z0-9_.:-]{0,127}");
|
||||
|
||||
public ProviderInstanceDefinition {
|
||||
Objects.requireNonNull(providerCode, "providerCode");
|
||||
Objects.requireNonNull(protocol, "protocol");
|
||||
Objects.requireNonNull(canonicalAuthority, "canonicalAuthority");
|
||||
Objects.requireNonNull(displayName, "displayName");
|
||||
Objects.requireNonNull(primarySubjectType, "primarySubjectType");
|
||||
Objects.requireNonNull(
|
||||
legacyPrimarySubjectType,
|
||||
"legacyPrimarySubjectType");
|
||||
Objects.requireNonNull(
|
||||
subjectNormalizations,
|
||||
"subjectNormalizations");
|
||||
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");
|
||||
}
|
||||
if (!SUBJECT_TYPE_PATTERN.matcher(primarySubjectType).matches()
|
||||
|| !SUBJECT_TYPE_PATTERN
|
||||
.matcher(legacyPrimarySubjectType)
|
||||
.matches()) {
|
||||
throw new IllegalArgumentException("Invalid subject type");
|
||||
}
|
||||
|
||||
subjectNormalizations = Map.copyOf(subjectNormalizations);
|
||||
if (!subjectNormalizations.containsKey(primarySubjectType)
|
||||
|| !subjectNormalizations
|
||||
.containsKey(legacyPrimarySubjectType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Primary subject types must have normalization rules");
|
||||
}
|
||||
if (subjectNormalizations.entrySet().stream()
|
||||
.anyMatch(entry -> entry.getKey() == null
|
||||
|| !SUBJECT_TYPE_PATTERN
|
||||
.matcher(entry.getKey())
|
||||
.matches()
|
||||
|| entry.getValue() == null)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid subject normalization rule");
|
||||
}
|
||||
|
||||
displayNameAttributes = copyAttributeKeys(
|
||||
displayNameAttributes,
|
||||
"displayNameAttributes");
|
||||
emailAttributes = copyAttributeKeys(
|
||||
emailAttributes,
|
||||
"emailAttributes");
|
||||
avatarAttributes = copyAttributeKeys(
|
||||
avatarAttributes,
|
||||
"avatarAttributes");
|
||||
}
|
||||
|
||||
public ProviderInstanceDefinition(
|
||||
String providerCode,
|
||||
String protocol,
|
||||
String canonicalAuthority,
|
||||
String displayName,
|
||||
String primarySubjectType,
|
||||
String legacyPrimarySubjectType,
|
||||
Map<String, SubjectNormalization> subjectNormalizations,
|
||||
List<String> displayNameAttributes,
|
||||
List<String> emailAttributes,
|
||||
List<String> avatarAttributes,
|
||||
EmailAssurance emailAssuranceLimit) {
|
||||
this(
|
||||
providerCode,
|
||||
protocol,
|
||||
canonicalAuthority,
|
||||
displayName,
|
||||
primarySubjectType,
|
||||
legacyPrimarySubjectType,
|
||||
subjectNormalizations,
|
||||
displayNameAttributes,
|
||||
emailAttributes,
|
||||
avatarAttributes,
|
||||
emailAssuranceLimit,
|
||||
true);
|
||||
}
|
||||
|
||||
private static List<String> copyAttributeKeys(
|
||||
List<String> values,
|
||||
String field) {
|
||||
List<String> copied = List.copyOf(values);
|
||||
if (copied.stream().anyMatch(value -> value == null
|
||||
|| !ATTRIBUTE_KEY_PATTERN.matcher(value).matches())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid provider attribute key in " + field);
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
/**
|
||||
* Trusted normalization strategy for a provider subject.
|
||||
*/
|
||||
public enum SubjectNormalization {
|
||||
EXACT,
|
||||
DECIMAL
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Trusted authentication-adapter contracts. Adapters produce protocol facts
|
||||
* or stable failure codes; the identity core owns provider routing, account
|
||||
* binding and sessions.
|
||||
*/
|
||||
package com.iflytek.skillhub.auth.provider;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProviderAuthenticationResultTest {
|
||||
|
||||
@Test
|
||||
void rejectsSecretBearingAttributesIncludingNamespacedKeys() {
|
||||
for (String key : List.of(
|
||||
"token",
|
||||
"access_token",
|
||||
"oauth.auth_token",
|
||||
"oidc.refresh_token",
|
||||
"upstream:password",
|
||||
"session.cookie",
|
||||
"cas:ticket",
|
||||
"raw_response",
|
||||
"clientSecret",
|
||||
"oauth.clientSecret",
|
||||
"clientsecret",
|
||||
"apiKey",
|
||||
"oauthApiKey",
|
||||
"accessToken",
|
||||
"refreshToken",
|
||||
"idToken",
|
||||
"privateKey",
|
||||
"clientAssertion",
|
||||
"rawResponse")) {
|
||||
assertThatThrownBy(() -> new ProviderAuthenticationResult(
|
||||
new SubjectCandidate("oidc_sub", "subject-1"),
|
||||
List.of(),
|
||||
Map.of(
|
||||
key,
|
||||
List.of(new ProviderAttributeValue(
|
||||
"secret",
|
||||
ProviderAttributeTrust.ASSERTED))),
|
||||
new ProtocolAuthenticationEvidence(
|
||||
"oidc",
|
||||
Instant.parse("2026-07-30T00:00:00Z"),
|
||||
Set.of("authorization_code"))))
|
||||
.as("attribute key %s", key)
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining(
|
||||
"Sensitive provider attributes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,26 @@
|
|||
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.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.CredentialAuthenticationRequest;
|
||||
import com.iflytek.skillhub.auth.provider.PassiveAuthenticationAdapter;
|
||||
import com.iflytek.skillhub.auth.provider.PassiveAuthenticationRequest;
|
||||
import com.iflytek.skillhub.auth.provider.ProviderInstanceDefinition;
|
||||
import com.iflytek.skillhub.auth.provider.SubjectNormalization;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -17,14 +28,15 @@ import org.mockito.InOrder;
|
|||
|
||||
class ReconciledIdentityProviderCatalogTest {
|
||||
|
||||
private TrustedProviderDescriptorSource descriptorSource;
|
||||
private ConfiguredProviderDescriptorSource descriptorSource;
|
||||
private ProviderAuthorityLockService authorityLockService;
|
||||
private IdentityBindingPreflightService bindingPreflightService;
|
||||
private ReconciledIdentityProviderCatalog catalog;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
descriptorSource = mock(TrustedProviderDescriptorSource.class);
|
||||
descriptorSource = mock(
|
||||
ConfiguredProviderDescriptorSource.class);
|
||||
authorityLockService = mock(ProviderAuthorityLockService.class);
|
||||
bindingPreflightService = mock(
|
||||
IdentityBindingPreflightService.class);
|
||||
|
|
@ -34,13 +46,16 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
catalog = new ReconciledIdentityProviderCatalog(
|
||||
descriptorSource,
|
||||
authorityLockService,
|
||||
bindingPreflightService);
|
||||
bindingPreflightService,
|
||||
new IdentityProviderPolicyProperties(),
|
||||
List.of(),
|
||||
List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishesProviderOnlyAfterPinAndPersistedStateReread() {
|
||||
ProviderDescriptor github = descriptor("github", "GitHub");
|
||||
when(descriptorSource.enabledDescriptors())
|
||||
when(descriptorSource.configuredDescriptors())
|
||||
.thenReturn(List.of(github));
|
||||
when(authorityLockService.isReady(github)).thenReturn(true);
|
||||
|
||||
|
|
@ -64,7 +79,7 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
void hidesProvidersWhosePinOrPersistedStateCheckFails() {
|
||||
ProviderDescriptor github = descriptor("github", "GitHub");
|
||||
ProviderDescriptor gitlab = descriptor("gitlab", "GitLab");
|
||||
when(descriptorSource.enabledDescriptors())
|
||||
when(descriptorSource.configuredDescriptors())
|
||||
.thenReturn(List.of(github, gitlab));
|
||||
doThrow(new IdentityCoreException(
|
||||
IdentityFailureCode.PROVIDER_AUTHORITY_MISMATCH))
|
||||
|
|
@ -81,7 +96,7 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
@Test
|
||||
void persistedStateReadFailureCannotExposePreviouslyReadyProvider() {
|
||||
ProviderDescriptor github = descriptor("github", "GitHub");
|
||||
when(descriptorSource.enabledDescriptors())
|
||||
when(descriptorSource.configuredDescriptors())
|
||||
.thenReturn(List.of(github));
|
||||
when(authorityLockService.isReady(github)).thenReturn(true);
|
||||
catalog.reconcile();
|
||||
|
|
@ -102,7 +117,7 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
@Test
|
||||
void reflectsSameAuthorityRecoveryWithoutApplicationRestart() {
|
||||
ProviderDescriptor github = descriptor("github", "GitHub");
|
||||
when(descriptorSource.enabledDescriptors())
|
||||
when(descriptorSource.configuredDescriptors())
|
||||
.thenReturn(List.of(github));
|
||||
when(authorityLockService.isReady(github))
|
||||
.thenReturn(false, true);
|
||||
|
|
@ -115,6 +130,166 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
"GitHub"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectsCredentialAndPassiveCapabilitiesFromOneProvider() {
|
||||
CredentialAuthenticationAdapter credential =
|
||||
new CredentialAuthenticationAdapter() {
|
||||
@Override
|
||||
public ProviderInstanceDefinition provider() {
|
||||
return definition(
|
||||
"private-sso",
|
||||
"https://sso.example");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderAuthenticationResult authenticate(
|
||||
CredentialAuthenticationRequest request) {
|
||||
throw new UnsupportedOperationException(
|
||||
"not called");
|
||||
}
|
||||
};
|
||||
PassiveAuthenticationAdapter passive =
|
||||
new PassiveAuthenticationAdapter() {
|
||||
@Override
|
||||
public ProviderInstanceDefinition provider() {
|
||||
return definition(
|
||||
"private-sso",
|
||||
"https://sso.example");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProviderAuthenticationResult> authenticate(
|
||||
PassiveAuthenticationRequest request) {
|
||||
throw new UnsupportedOperationException(
|
||||
"not called");
|
||||
}
|
||||
};
|
||||
when(descriptorSource.configuredDescriptors())
|
||||
.thenReturn(List.of());
|
||||
when(authorityLockService.isReady(any()))
|
||||
.thenReturn(true);
|
||||
catalog = new ReconciledIdentityProviderCatalog(
|
||||
descriptorSource,
|
||||
authorityLockService,
|
||||
bindingPreflightService,
|
||||
new IdentityProviderPolicyProperties(),
|
||||
List.of(credential),
|
||||
List.of(passive));
|
||||
|
||||
catalog.reconcile();
|
||||
|
||||
assertThat(catalog.listReadyLoginMethods())
|
||||
.containsExactly(
|
||||
new IdentityProviderLoginMethod(
|
||||
"private-sso",
|
||||
"Private SSO",
|
||||
IdentityProviderLoginMethodType
|
||||
.DIRECT_PASSWORD),
|
||||
new IdentityProviderLoginMethod(
|
||||
"private-sso",
|
||||
"Private SSO",
|
||||
IdentityProviderLoginMethodType
|
||||
.SESSION_BOOTSTRAP));
|
||||
assertThat(catalog.requireCredentialRoute("private-sso")
|
||||
.adapter()).isSameAs(credential);
|
||||
assertThat(catalog.requirePassiveRoute("private-sso")
|
||||
.adapter()).isSameAs(passive);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAdapterIsNotRoutableOrInvoked() {
|
||||
CredentialAuthenticationAdapter adapter =
|
||||
mock(CredentialAuthenticationAdapter.class);
|
||||
ProviderInstanceDefinition disabled = new ProviderInstanceDefinition(
|
||||
"disabled",
|
||||
"ldap",
|
||||
"ldap://directory.example",
|
||||
"Disabled Directory",
|
||||
"ldap_entry_uuid",
|
||||
"ldap_entry_uuid",
|
||||
Map.of(
|
||||
"ldap_entry_uuid",
|
||||
SubjectNormalization.EXACT),
|
||||
List.of("displayName"),
|
||||
List.of("mail"),
|
||||
List.of(),
|
||||
EmailAssurance.VERIFIED,
|
||||
false);
|
||||
when(adapter.provider()).thenReturn(disabled);
|
||||
when(descriptorSource.configuredDescriptors())
|
||||
.thenReturn(List.of());
|
||||
catalog = new ReconciledIdentityProviderCatalog(
|
||||
descriptorSource,
|
||||
authorityLockService,
|
||||
bindingPreflightService,
|
||||
new IdentityProviderPolicyProperties(),
|
||||
List.of(adapter),
|
||||
List.of());
|
||||
|
||||
catalog.reconcile();
|
||||
|
||||
assertThat(catalog.listReadyLoginMethods()).isEmpty();
|
||||
assertThatThrownBy(
|
||||
() -> catalog.requireCredentialRoute("disabled"))
|
||||
.isInstanceOf(IdentityCoreException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(IdentityFailureCode.PROVIDER_DISABLED);
|
||||
verify(adapter, never()).authenticate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void conflictingTrustedDefinitionsFailClosed() {
|
||||
CredentialAuthenticationAdapter credential =
|
||||
mock(CredentialAuthenticationAdapter.class);
|
||||
PassiveAuthenticationAdapter passive =
|
||||
mock(PassiveAuthenticationAdapter.class);
|
||||
when(credential.provider()).thenReturn(definition(
|
||||
"private-sso",
|
||||
"https://one.example"));
|
||||
when(passive.provider()).thenReturn(definition(
|
||||
"private-sso",
|
||||
"https://two.example"));
|
||||
when(descriptorSource.configuredDescriptors())
|
||||
.thenReturn(List.of());
|
||||
catalog = new ReconciledIdentityProviderCatalog(
|
||||
descriptorSource,
|
||||
authorityLockService,
|
||||
bindingPreflightService,
|
||||
new IdentityProviderPolicyProperties(),
|
||||
List.of(credential),
|
||||
List.of(passive));
|
||||
|
||||
catalog.reconcile();
|
||||
|
||||
assertThat(catalog.listReadyLoginMethods()).isEmpty();
|
||||
assertThat(catalog.enabledDescriptors()).isEmpty();
|
||||
verify(credential, never()).authenticate(any());
|
||||
verify(passive, never()).authenticate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void misconfiguredAdapterCannotBecomeRoutable() {
|
||||
CredentialAuthenticationAdapter adapter =
|
||||
mock(CredentialAuthenticationAdapter.class);
|
||||
when(adapter.provider()).thenThrow(
|
||||
new IllegalArgumentException(
|
||||
"invalid trusted configuration"));
|
||||
when(descriptorSource.configuredDescriptors())
|
||||
.thenReturn(List.of());
|
||||
catalog = new ReconciledIdentityProviderCatalog(
|
||||
descriptorSource,
|
||||
authorityLockService,
|
||||
bindingPreflightService,
|
||||
new IdentityProviderPolicyProperties(),
|
||||
List.of(adapter),
|
||||
List.of());
|
||||
|
||||
catalog.reconcile();
|
||||
|
||||
assertThat(catalog.listReadyLoginMethods()).isEmpty();
|
||||
verify(adapter, never()).authenticate(any());
|
||||
}
|
||||
|
||||
private static ProviderDescriptor descriptor(
|
||||
String providerCode,
|
||||
String displayName) {
|
||||
|
|
@ -133,4 +308,23 @@ class ReconciledIdentityProviderCatalogTest {
|
|||
List.of("picture"),
|
||||
EmailAssurance.VERIFIED);
|
||||
}
|
||||
|
||||
private static ProviderInstanceDefinition definition(
|
||||
String providerCode,
|
||||
String authority) {
|
||||
return new ProviderInstanceDefinition(
|
||||
providerCode,
|
||||
"private-sso",
|
||||
authority,
|
||||
"Private SSO",
|
||||
"private_subject",
|
||||
"private_subject",
|
||||
Map.of(
|
||||
"private_subject",
|
||||
SubjectNormalization.EXACT),
|
||||
List.of("display_name"),
|
||||
List.of("email"),
|
||||
List.of("avatar_url"),
|
||||
EmailAssurance.VERIFIED);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
/**
|
||||
* Test-only factory for the sealed provider handle.
|
||||
*/
|
||||
public final class ResolvedProviderHandleTestFixture {
|
||||
|
||||
private ResolvedProviderHandleTestFixture() {
|
||||
}
|
||||
|
||||
public static ResolvedProviderHandle handle(
|
||||
String providerCode) {
|
||||
return new DefaultResolvedProviderHandle(providerCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,10 +21,11 @@ class StaticTrustedProviderDescriptorSourceTest {
|
|||
Set.of("github"),
|
||||
github);
|
||||
|
||||
ResolvedProviderHandle handle = source.resolve(github);
|
||||
ProviderDescriptor descriptor = source.require(handle);
|
||||
String providerCode =
|
||||
source.resolveBrowserProviderCode(github);
|
||||
ProviderDescriptor descriptor = descriptor(source, providerCode);
|
||||
|
||||
assertThat(handle.providerCode()).isEqualTo("github");
|
||||
assertThat(providerCode).isEqualTo("github");
|
||||
assertThat(descriptor.protocol()).isEqualTo("oauth2-github");
|
||||
assertThat(descriptor.canonicalAuthority())
|
||||
.isEqualTo("https://github.com");
|
||||
|
|
@ -63,8 +64,7 @@ class StaticTrustedProviderDescriptorSourceTest {
|
|||
Set.of("github"),
|
||||
policies);
|
||||
|
||||
ProviderDescriptor descriptor =
|
||||
source.require(source.resolve(github));
|
||||
ProviderDescriptor descriptor = descriptor(source, "github");
|
||||
|
||||
assertThat(descriptor.provisioningMode())
|
||||
.isEqualTo(ProvisioningMode.APPROVAL);
|
||||
|
|
@ -83,7 +83,8 @@ class StaticTrustedProviderDescriptorSourceTest {
|
|||
Set.of("github"),
|
||||
trustedGithub);
|
||||
|
||||
assertThatThrownBy(() -> source.resolve(github()))
|
||||
assertThatThrownBy(
|
||||
() -> source.resolveBrowserProviderCode(github()))
|
||||
.isInstanceOf(IdentityCoreException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(IdentityFailureCode.PROVIDER_DISABLED);
|
||||
|
|
@ -98,8 +99,7 @@ class StaticTrustedProviderDescriptorSourceTest {
|
|||
Set.of("gitlab"),
|
||||
gitlab);
|
||||
|
||||
ProviderDescriptor descriptor =
|
||||
source.require(source.resolve(gitlab));
|
||||
ProviderDescriptor descriptor = descriptor(source, "gitlab");
|
||||
|
||||
assertThat(descriptor.protocol()).isEqualTo("oauth2-gitlab");
|
||||
assertThat(descriptor.canonicalAuthority())
|
||||
|
|
@ -119,7 +119,7 @@ class StaticTrustedProviderDescriptorSourceTest {
|
|||
oidc);
|
||||
|
||||
ProviderDescriptor descriptor =
|
||||
source.require(source.resolve(oidc));
|
||||
descriptor(source, "corp-oidc");
|
||||
|
||||
assertThat(descriptor.protocol()).isEqualTo("oidc");
|
||||
assertThat(descriptor.canonicalAuthority())
|
||||
|
|
@ -146,12 +146,14 @@ class StaticTrustedProviderDescriptorSourceTest {
|
|||
github,
|
||||
unsupported);
|
||||
|
||||
assertThat(source.enabledDescriptors()).isEmpty();
|
||||
assertThatThrownBy(() -> source.resolve(github))
|
||||
assertThat(source.configuredDescriptors()).isEmpty();
|
||||
assertThatThrownBy(
|
||||
() -> source.resolveBrowserProviderCode(github))
|
||||
.isInstanceOf(IdentityCoreException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(IdentityFailureCode.PROVIDER_DISABLED);
|
||||
assertThatThrownBy(() -> source.resolve(unsupported))
|
||||
assertThatThrownBy(
|
||||
() -> source.resolveBrowserProviderCode(unsupported))
|
||||
.isInstanceOf(IdentityCoreException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(IdentityFailureCode.PROVIDER_DISABLED);
|
||||
|
|
@ -167,8 +169,9 @@ class StaticTrustedProviderDescriptorSourceTest {
|
|||
Set.of("custom"),
|
||||
ambiguous);
|
||||
|
||||
assertThat(source.enabledDescriptors()).isEmpty();
|
||||
assertThatThrownBy(() -> source.resolve(ambiguous))
|
||||
assertThat(source.configuredDescriptors()).isEmpty();
|
||||
assertThatThrownBy(
|
||||
() -> source.resolveBrowserProviderCode(ambiguous))
|
||||
.isInstanceOf(IdentityCoreException.class)
|
||||
.extracting("reasonCode")
|
||||
.isEqualTo(IdentityFailureCode.PROVIDER_DISABLED);
|
||||
|
|
@ -186,6 +189,16 @@ class StaticTrustedProviderDescriptorSourceTest {
|
|||
extractorCodes);
|
||||
}
|
||||
|
||||
private static ProviderDescriptor descriptor(
|
||||
StaticTrustedProviderDescriptorSource source,
|
||||
String providerCode) {
|
||||
return source.configuredDescriptors().stream()
|
||||
.filter(candidate -> candidate.providerCode()
|
||||
.equals(providerCode))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
|
||||
private static OAuth2ClientProperties.Registration properties(
|
||||
String clientId,
|
||||
String clientName) {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ 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.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
|
||||
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.identity.ResolvedProviderHandle;
|
||||
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandleTestFixture;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
|
@ -61,10 +64,15 @@ class CustomOidcUserServiceTest {
|
|||
"https://idp.example/avatar.png",
|
||||
"okta",
|
||||
Set.of("USER", "SUPER_ADMIN"));
|
||||
ResolvedProviderHandle provider =
|
||||
ResolvedProviderHandleTestFixture.handle("okta");
|
||||
when(loginFlowService.requireReadyProvider(
|
||||
request.getClientRegistration()))
|
||||
.thenReturn(provider);
|
||||
when(delegate.loadUser(request)).thenReturn(upstreamUser);
|
||||
when(contextResolver.current()).thenReturn(loginContext);
|
||||
when(loginFlowService.authenticate(
|
||||
eq(request.getClientRegistration()),
|
||||
eq(provider),
|
||||
any(ProviderAuthenticationResult.class),
|
||||
eq(loginContext)))
|
||||
.thenReturn(platformPrincipal);
|
||||
|
|
@ -75,7 +83,7 @@ class CustomOidcUserServiceTest {
|
|||
ArgumentCaptor.forClass(
|
||||
ProviderAuthenticationResult.class);
|
||||
verify(loginFlowService).authenticate(
|
||||
eq(request.getClientRegistration()),
|
||||
eq(provider),
|
||||
resultCaptor.capture(),
|
||||
eq(loginContext));
|
||||
ProviderAuthenticationResult result = resultCaptor.getValue();
|
||||
|
|
@ -101,6 +109,30 @@ class CustomOidcUserServiceTest {
|
|||
.contains("ROLE_USER", "ROLE_SUPER_ADMIN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableRouteCannotInvokeOidcUpstream() {
|
||||
OAuthLoginFlowService loginFlowService =
|
||||
mock(OAuthLoginFlowService.class);
|
||||
OAuth2UserService<OidcUserRequest, OidcUser> delegate = mock();
|
||||
OAuthIdentityLoginContextResolver contextResolver = mock();
|
||||
CustomOidcUserService service =
|
||||
new CustomOidcUserService(
|
||||
loginFlowService,
|
||||
delegate,
|
||||
contextResolver);
|
||||
OidcUserRequest request = oidcRequest();
|
||||
when(loginFlowService.requireReadyProvider(
|
||||
request.getClientRegistration()))
|
||||
.thenThrow(new OAuth2AuthenticationException(
|
||||
new org.springframework.security.oauth2.core.OAuth2Error(
|
||||
"provider_disabled")));
|
||||
|
||||
assertThatThrownBy(() -> service.loadUser(request))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
|
||||
verifyNoInteractions(delegate, contextResolver);
|
||||
}
|
||||
|
||||
@Test
|
||||
void conversionPreservesUnverifiedEmailAsUntrustedFact() {
|
||||
ProviderAuthenticationResult result =
|
||||
|
|
@ -198,6 +230,11 @@ class CustomOidcUserServiceTest {
|
|||
"email", "user@company.com",
|
||||
"email_verified", false,
|
||||
"preferred_username", "unverified-user"));
|
||||
ResolvedProviderHandle provider =
|
||||
ResolvedProviderHandleTestFixture.handle("okta");
|
||||
when(loginFlowService.requireReadyProvider(
|
||||
request.getClientRegistration()))
|
||||
.thenReturn(provider);
|
||||
when(delegate.loadUser(request)).thenReturn(upstreamUser);
|
||||
when(contextResolver.current()).thenReturn(loginContext);
|
||||
|
||||
|
|
@ -205,7 +242,7 @@ class CustomOidcUserServiceTest {
|
|||
ArgumentCaptor.forClass(
|
||||
ProviderAuthenticationResult.class);
|
||||
when(loginFlowService.authenticate(
|
||||
eq(request.getClientRegistration()),
|
||||
eq(provider),
|
||||
resultCaptor.capture(),
|
||||
eq(loginContext)))
|
||||
.thenThrow(new OAuth2AuthenticationException(
|
||||
|
|
|
|||
|
|
@ -3,8 +3,13 @@ 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.clearInvocations;
|
||||
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.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService;
|
||||
|
|
@ -14,6 +19,8 @@ import com.iflytek.skillhub.auth.identity.IdentityLoginContext;
|
|||
import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome;
|
||||
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle;
|
||||
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandleTestFixture;
|
||||
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
|
||||
import com.iflytek.skillhub.auth.identity.TrustedProviderRouteResolver;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
|
|
@ -23,14 +30,105 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
class OAuthLoginFlowServiceTest {
|
||||
|
||||
@Test
|
||||
void resolvesReadyRouteBeforeOAuthUpstreamAndAdapterCalls() {
|
||||
OAuthClaimsExtractor extractor = mock(OAuthClaimsExtractor.class);
|
||||
when(extractor.getProvider()).thenReturn("github");
|
||||
TrustedProviderRouteResolver resolver =
|
||||
mock(TrustedProviderRouteResolver.class);
|
||||
ExternalIdentityLoginService identityLoginService =
|
||||
mock(ExternalIdentityLoginService.class);
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate =
|
||||
mock();
|
||||
OAuth2UserRequest request = mock(OAuth2UserRequest.class);
|
||||
OAuth2User upstreamUser = mock(OAuth2User.class);
|
||||
ResolvedProviderHandle provider =
|
||||
ResolvedProviderHandleTestFixture.handle("github");
|
||||
ClientRegistration registration = registration();
|
||||
when(request.getClientRegistration()).thenReturn(registration);
|
||||
when(resolver.resolve(registration)).thenReturn(provider);
|
||||
when(delegate.loadUser(request)).thenReturn(upstreamUser);
|
||||
when(extractor.authenticate(any())).thenReturn(result());
|
||||
when(identityLoginService.authenticate(
|
||||
eq(provider),
|
||||
any(),
|
||||
eq(context())))
|
||||
.thenReturn(new IdentityLoginOutcome.Authenticated(
|
||||
principal(),
|
||||
false,
|
||||
false));
|
||||
OAuthLoginFlowService service =
|
||||
new OAuthLoginFlowService(
|
||||
List.of(extractor),
|
||||
resolver,
|
||||
identityLoginService,
|
||||
delegate);
|
||||
clearInvocations(extractor);
|
||||
|
||||
service.loadLoginContext(request, context());
|
||||
|
||||
InOrder order = inOrder(
|
||||
resolver,
|
||||
delegate,
|
||||
extractor,
|
||||
identityLoginService);
|
||||
order.verify(resolver).resolve(registration);
|
||||
order.verify(delegate).loadUser(request);
|
||||
order.verify(extractor).authenticate(any());
|
||||
order.verify(identityLoginService).authenticate(
|
||||
eq(provider),
|
||||
any(),
|
||||
eq(context()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableRouteCannotInvokeOAuthUpstreamOrAdapter() {
|
||||
OAuthClaimsExtractor extractor = mock(OAuthClaimsExtractor.class);
|
||||
when(extractor.getProvider()).thenReturn("github");
|
||||
TrustedProviderRouteResolver resolver =
|
||||
mock(TrustedProviderRouteResolver.class);
|
||||
ExternalIdentityLoginService identityLoginService =
|
||||
mock(ExternalIdentityLoginService.class);
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate =
|
||||
mock();
|
||||
OAuth2UserRequest request = mock(OAuth2UserRequest.class);
|
||||
ClientRegistration registration = registration();
|
||||
when(request.getClientRegistration()).thenReturn(registration);
|
||||
when(resolver.resolve(registration)).thenThrow(
|
||||
new IdentityCoreException(
|
||||
IdentityFailureCode.PROVIDER_DISABLED));
|
||||
OAuthLoginFlowService service =
|
||||
new OAuthLoginFlowService(
|
||||
List.of(extractor),
|
||||
resolver,
|
||||
identityLoginService,
|
||||
delegate);
|
||||
clearInvocations(extractor);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
service.loadLoginContext(request, context()))
|
||||
.isInstanceOfSatisfying(
|
||||
OAuth2AuthenticationException.class,
|
||||
exception -> assertThat(
|
||||
exception.getError().getErrorCode())
|
||||
.isEqualTo("provider_disabled"));
|
||||
|
||||
verifyNoInteractions(delegate, identityLoginService);
|
||||
verify(extractor, never()).authenticate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticateReturnsPrincipalOnlyForAuthenticatedOutcome() {
|
||||
TrustedProviderRouteResolver resolver =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PassiveAuthenticationRequestTest {
|
||||
|
||||
@Test
|
||||
void copiesAndNormalizesHeadersWithoutExposingMutableState() {
|
||||
ArrayList<String> values = new ArrayList<>(
|
||||
List.of("first"));
|
||||
Map<String, List<String>> headers =
|
||||
new LinkedHashMap<>();
|
||||
headers.put("X-Identity-Assertion", values);
|
||||
headers.put(
|
||||
"x-identity-assertion",
|
||||
List.of("second"));
|
||||
|
||||
PassiveAuthenticationRequest request =
|
||||
new PassiveAuthenticationRequest(
|
||||
"post",
|
||||
"/api/v1/auth/session/bootstrap",
|
||||
"source=portal",
|
||||
"203.0.113.9",
|
||||
headers);
|
||||
values.add("mutated");
|
||||
headers.clear();
|
||||
|
||||
assertThat(request.method()).isEqualTo("POST");
|
||||
assertThat(request.headerValues("X-IDENTITY-ASSERTION"))
|
||||
.containsExactly("first", "second");
|
||||
assertThat(request.headers())
|
||||
.containsOnlyKeys("x-identity-assertion");
|
||||
assertThatThrownBy(() ->
|
||||
request.headers().put("other", List.of("value")))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingTransportIdentity() {
|
||||
assertThatThrownBy(() ->
|
||||
new PassiveAuthenticationRequest(
|
||||
" ",
|
||||
"/bootstrap",
|
||||
null,
|
||||
null,
|
||||
Map.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("method");
|
||||
assertThatThrownBy(() ->
|
||||
new PassiveAuthenticationRequest(
|
||||
"POST",
|
||||
" ",
|
||||
null,
|
||||
null,
|
||||
Map.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("URI");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.EmailAssurance;
|
||||
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 java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reusable assertions for trusted in-tree provider adapters.
|
||||
*
|
||||
* <p>Each adapter test supplies deterministic protocol fixtures and invokes
|
||||
* the relevant capability assertion. Network error classification and timeout
|
||||
* cases remain protocol-specific tests beside the adapter.</p>
|
||||
*/
|
||||
public final class ProviderConformanceKit {
|
||||
|
||||
private static final List<String> FORBIDDEN_DEPENDENCIES = List.of(
|
||||
"com/iflytek/skillhub/auth/rbac/PlatformPrincipal",
|
||||
"com/iflytek/skillhub/auth/identity/IdentityAssertionFactory",
|
||||
"com/iflytek/skillhub/auth/identity/PlatformPrincipalFactory",
|
||||
"com/iflytek/skillhub/auth/session/PlatformSessionService",
|
||||
"com/iflytek/skillhub/auth/repository/",
|
||||
"com/iflytek/skillhub/domain/user/UserAccount",
|
||||
"com/iflytek/skillhub/domain/namespace/",
|
||||
"jakarta/persistence/",
|
||||
"jakarta/servlet/",
|
||||
"javax/servlet/",
|
||||
"org/springframework/data/jpa/",
|
||||
"org/springframework/security/core/context/",
|
||||
"org/springframework/security/web/context/");
|
||||
|
||||
private ProviderConformanceKit() {
|
||||
}
|
||||
|
||||
public static <T> ProviderAuthenticationResult verifyBrowser(
|
||||
ProviderInstanceDefinition provider,
|
||||
BrowserAuthenticationAdapter<T> adapter,
|
||||
T fixture) {
|
||||
assertThat(provider).isNotNull();
|
||||
ProviderAuthenticationResult result =
|
||||
adapter.authenticate(fixture);
|
||||
verifyResult(provider, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static ProviderAuthenticationResult verifyCredential(
|
||||
CredentialAuthenticationAdapter adapter,
|
||||
CredentialAuthenticationRequest fixture) {
|
||||
ProviderInstanceDefinition provider =
|
||||
verifyDefinition(adapter.provider(), adapter.provider());
|
||||
ProviderAuthenticationResult result =
|
||||
adapter.authenticate(fixture);
|
||||
verifyResult(provider, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static ProviderAuthenticationResult verifyPassive(
|
||||
PassiveAuthenticationAdapter adapter,
|
||||
PassiveAuthenticationRequest fixture) {
|
||||
ProviderInstanceDefinition provider =
|
||||
verifyDefinition(adapter.provider(), adapter.provider());
|
||||
Optional<ProviderAuthenticationResult> authentication =
|
||||
adapter.authenticate(fixture);
|
||||
assertThat(authentication)
|
||||
.as("positive passive fixture must return an Optional")
|
||||
.isNotNull()
|
||||
.isPresent();
|
||||
ProviderAuthenticationResult result = authentication.orElseThrow();
|
||||
verifyResult(provider, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies stable subjects from two equivalent, independently valid
|
||||
* protocol fixtures. Passive adapters should use distinct nonces or
|
||||
* assertions so this check does not conflict with replay protection.
|
||||
*/
|
||||
public static void verifyStableSubjects(
|
||||
ProviderInstanceDefinition provider,
|
||||
ProviderAuthenticationResult first,
|
||||
ProviderAuthenticationResult second) {
|
||||
verifyResult(provider, first);
|
||||
verifyResult(provider, second);
|
||||
assertThat(second.primarySubject())
|
||||
.as("equivalent fixtures must produce a stable primary subject")
|
||||
.isEqualTo(first.primarySubject());
|
||||
assertThat(second.alternateSubjects())
|
||||
.as("equivalent fixtures must produce stable alternate subjects")
|
||||
.isEqualTo(first.alternateSubjects());
|
||||
}
|
||||
|
||||
public static void verifyResult(
|
||||
ProviderInstanceDefinition provider,
|
||||
ProviderAuthenticationResult result) {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.evidence().protocol())
|
||||
.isEqualTo(provider.protocol());
|
||||
assertAllowedSubject(provider, result.primarySubject());
|
||||
for (SubjectCandidate alternate : result.alternateSubjects()) {
|
||||
assertAllowedSubject(provider, alternate);
|
||||
}
|
||||
assertEmailAssurance(provider, result);
|
||||
}
|
||||
|
||||
public static void verifyAdapterBoundary(Class<?>... adapterClasses)
|
||||
throws IOException {
|
||||
for (Class<?> adapterClass : adapterClasses) {
|
||||
String bytecode = classFileConstants(adapterClass);
|
||||
assertThat(FORBIDDEN_DEPENDENCIES)
|
||||
.as(
|
||||
"forbidden dependencies of %s",
|
||||
adapterClass.getName())
|
||||
.noneMatch(bytecode::contains);
|
||||
}
|
||||
}
|
||||
|
||||
private static ProviderInstanceDefinition verifyDefinition(
|
||||
ProviderInstanceDefinition first,
|
||||
ProviderInstanceDefinition second) {
|
||||
assertThat(first)
|
||||
.as("provider definition is required")
|
||||
.isNotNull();
|
||||
assertThat(second)
|
||||
.as("provider definition must be deterministic")
|
||||
.isEqualTo(first);
|
||||
return first;
|
||||
}
|
||||
|
||||
private static void assertAllowedSubject(
|
||||
ProviderInstanceDefinition provider,
|
||||
SubjectCandidate subject) {
|
||||
assertThat(provider.subjectNormalizations())
|
||||
.as("subject type must be declared by the provider")
|
||||
.containsKey(subject.type());
|
||||
assertThat(subject.value()).isNotBlank();
|
||||
}
|
||||
|
||||
private static void assertEmailAssurance(
|
||||
ProviderInstanceDefinition provider,
|
||||
ProviderAuthenticationResult result) {
|
||||
for (String attribute : provider.emailAttributes()) {
|
||||
for (ProviderAttributeValue value : result.attributes()
|
||||
.getOrDefault(attribute, List.of())) {
|
||||
EmailAssurance asserted =
|
||||
assurance(value.trust());
|
||||
assertThat(asserted.ordinal())
|
||||
.as(
|
||||
"email assurance for attribute %s",
|
||||
attribute)
|
||||
.isLessThanOrEqualTo(
|
||||
provider.emailAssuranceLimit().ordinal());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static EmailAssurance assurance(
|
||||
ProviderAttributeTrust trust) {
|
||||
return switch (trust) {
|
||||
case UNVERIFIED -> EmailAssurance.UNVERIFIED;
|
||||
case ASSERTED -> EmailAssurance.PROVIDER_ASSERTED;
|
||||
case VERIFIED -> EmailAssurance.VERIFIED;
|
||||
};
|
||||
}
|
||||
|
||||
private static String classFileConstants(Class<?> type)
|
||||
throws IOException {
|
||||
String resource = "/"
|
||||
+ type.getName().replace('.', '/')
|
||||
+ ".class";
|
||||
try (InputStream input =
|
||||
type.getResourceAsStream(resource)) {
|
||||
assertThat(input)
|
||||
.as(
|
||||
"compiled class resource for %s",
|
||||
type.getName())
|
||||
.isNotNull();
|
||||
return new String(
|
||||
input.readAllBytes(),
|
||||
StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
package com.iflytek.skillhub.auth.provider;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.EmailAssurance;
|
||||
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.oauth.GitHubClaimsExtractor;
|
||||
import com.iflytek.skillhub.auth.oauth.GitLabClaimsExtractor;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProviderConformanceKitTest {
|
||||
|
||||
@Test
|
||||
void verifiesDeterministicDefinitionAndProtocolFacts() {
|
||||
AtomicReference<CredentialAuthenticationRequest> observed =
|
||||
new AtomicReference<>();
|
||||
CredentialAuthenticationAdapter adapter =
|
||||
new CredentialAuthenticationAdapter() {
|
||||
@Override
|
||||
public ProviderInstanceDefinition provider() {
|
||||
return providerDefinition();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderAuthenticationResult authenticate(
|
||||
CredentialAuthenticationRequest request) {
|
||||
observed.set(request);
|
||||
return authenticationResult();
|
||||
}
|
||||
};
|
||||
CredentialAuthenticationRequest fixture =
|
||||
new CredentialAuthenticationRequest(
|
||||
"alice",
|
||||
"fixture-password");
|
||||
|
||||
ProviderAuthenticationResult result =
|
||||
ProviderConformanceKit.verifyCredential(
|
||||
adapter,
|
||||
fixture);
|
||||
ProviderConformanceKit.verifyStableSubjects(
|
||||
adapter.provider(),
|
||||
result,
|
||||
adapter.authenticate(fixture));
|
||||
|
||||
assertThat(result.primarySubject().value())
|
||||
.isEqualTo("entry-123");
|
||||
assertThat(observed.get()).isEqualTo(fixture);
|
||||
}
|
||||
|
||||
@Test
|
||||
void providerAdaptersCannotReachAccountsRolesSessionsOrPersistence()
|
||||
throws IOException {
|
||||
ProviderConformanceKit.verifyAdapterBoundary(
|
||||
BrowserAuthenticationAdapter.class,
|
||||
CredentialAuthenticationAdapter.class,
|
||||
PassiveAuthenticationAdapter.class,
|
||||
GitHubClaimsExtractor.class,
|
||||
GitLabClaimsExtractor.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifiesBrowserAndPassivePositiveFixtures() throws IOException {
|
||||
ProviderInstanceDefinition provider = providerDefinition();
|
||||
ProviderAuthenticationResult result = authenticationResult();
|
||||
PassiveAuthenticationRequest fixture =
|
||||
new PassiveAuthenticationRequest(
|
||||
"POST",
|
||||
"/api/v1/auth/session/bootstrap",
|
||||
null,
|
||||
"127.0.0.1",
|
||||
Map.of(
|
||||
"X-Private-Assertion",
|
||||
List.of("fixture-assertion")));
|
||||
BrowserAuthenticationAdapter<String> browser =
|
||||
exchange -> result;
|
||||
PassiveAuthenticationAdapter passive =
|
||||
new PassiveAuthenticationAdapter() {
|
||||
@Override
|
||||
public ProviderInstanceDefinition provider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProviderAuthenticationResult> authenticate(
|
||||
PassiveAuthenticationRequest request) {
|
||||
assertThat(request).isEqualTo(fixture);
|
||||
return Optional.of(result);
|
||||
}
|
||||
};
|
||||
|
||||
assertThat(ProviderConformanceKit.verifyBrowser(
|
||||
provider,
|
||||
browser,
|
||||
"verified-exchange")).isSameAs(result);
|
||||
assertThat(ProviderConformanceKit.verifyPassive(
|
||||
passive,
|
||||
fixture))
|
||||
.isSameAs(result);
|
||||
ProviderConformanceKit.verifyAdapterBoundary(
|
||||
passive.getClass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmailTrustAboveProviderLimit() {
|
||||
ProviderInstanceDefinition provider =
|
||||
providerDefinition(EmailAssurance.UNVERIFIED);
|
||||
|
||||
assertThatThrownBy(() -> ProviderConformanceKit.verifyResult(
|
||||
provider,
|
||||
authenticationResult()))
|
||||
.isInstanceOf(AssertionError.class)
|
||||
.hasMessageContaining("email assurance");
|
||||
}
|
||||
|
||||
private static ProviderInstanceDefinition providerDefinition() {
|
||||
return providerDefinition(EmailAssurance.VERIFIED);
|
||||
}
|
||||
|
||||
private static ProviderInstanceDefinition providerDefinition(
|
||||
EmailAssurance emailAssuranceLimit) {
|
||||
return new ProviderInstanceDefinition(
|
||||
"directory",
|
||||
"ldap",
|
||||
"ldaps://directory.example",
|
||||
"Corporate Directory",
|
||||
"ldap_entry_uuid",
|
||||
"ldap_entry_uuid",
|
||||
Map.of(
|
||||
"ldap_entry_uuid",
|
||||
SubjectNormalization.EXACT),
|
||||
List.of("displayName"),
|
||||
List.of("mail"),
|
||||
List.of("jpegPhoto"),
|
||||
emailAssuranceLimit);
|
||||
}
|
||||
|
||||
private static ProviderAuthenticationResult authenticationResult() {
|
||||
return new ProviderAuthenticationResult(
|
||||
new SubjectCandidate(
|
||||
"ldap_entry_uuid",
|
||||
"entry-123"),
|
||||
List.of(),
|
||||
Map.of(
|
||||
"displayName",
|
||||
List.of(new ProviderAttributeValue(
|
||||
"Alice",
|
||||
ProviderAttributeTrust.ASSERTED)),
|
||||
"mail",
|
||||
List.of(new ProviderAttributeValue(
|
||||
"alice@example.com",
|
||||
ProviderAttributeTrust.VERIFIED))),
|
||||
new ProtocolAuthenticationEvidence(
|
||||
"ldap",
|
||||
Instant.parse("2026-07-30T00:00:00Z"),
|
||||
Set.of("password")));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue