skillhub/docs/12-private-sso-integration-playbook.md

438 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 私有 SSO 接入兼容层实施手册
## 1. 文档目的
本文档面向两类读者:
- 后续在私有仓库中接入企业 SSO 的开发者
- 需要基于当前开源版兼容层继续开发的 coding agent
本文档不是认证架构总览,而是实施手册。目标是让后续执行者在不了解全部历史上下文的情况下,也能基于当前成果直接开始接入工作,并且尽量把私有仓库与开源仓库的差异控制在 provider 实现层和少量配置层。
相关文档:
- [03-authentication-design.md](/Users/xudongsun/github/skillhub/docs/03-authentication-design.md)
- [06-api-design.md](/Users/xudongsun/github/skillhub/docs/06-api-design.md)
- [08-frontend-architecture.md](/Users/xudongsun/github/skillhub/docs/08-frontend-architecture.md)
- [11-auth-extensibility-and-private-sso.md](/Users/xudongsun/github/skillhub/docs/11-auth-extensibility-and-private-sso.md)
## 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](/Users/xudongsun/github/skillhub/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/direct/DirectAuthProvider.java)
- [PassiveSessionAuthenticator.java](/Users/xudongsun/github/skillhub/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/bootstrap/PassiveSessionAuthenticator.java)
- [PlatformSessionService.java](/Users/xudongsun/github/skillhub/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
```
要求:
- `DirectAuthProvider.providerCode()``PassiveSessionAuthenticator.providerCode()` 返回同一个值
- 不要为“用户名密码登录”和“Cookie 登录”定义两个不同 provider code
- 如需更友好的登录页文案,请同时覆盖 provider 的 `displayName()`,避免前端再维护一份私有显示名映射
#### 步骤 2封装 SSO 客户端
不要在 provider 实现里直接散落 HTTP 或 RPC 调用。建议先抽一层私有客户端:
```java
public interface PrivateSsoClient {
PrivateSsoUser verifyPassword(String username, String password);
Optional<PrivateSsoUser> verifySession(HttpServletRequest request);
}
```
其中 `PrivateSsoUser` 至少应包含:
- `uid`
- `username`
- `displayName`
最佳实践:
- 所有超时、重试、日志脱敏、错误码翻译都放在客户端层
- provider 层只负责把外部结果映射成平台所需的身份对象
- 禁止记录明文密码
#### 步骤 3实现用户映射服务
私有 SSO 不依赖 email也不做账号合并因此建议私有版实现一个专用服务
```java
public interface PrivateSsoIdentityService {
PlatformPrincipal resolveOrCreate(PrivateSsoUser ssoUser);
}
```
推荐逻辑:
1.`providerCode=private-sso``subject=ssoUid` 查现有绑定
2. 若已存在,加载对应平台用户
3. 若不存在,则自动创建平台用户
4. 创建新的身份绑定
5. 返回 `PlatformPrincipal`
要求:
- 自动创建出的用户默认应是 `ACTIVE`
- 不要尝试和现有本地账号或 OAuth 账号按 email 合并
#### 步骤 4实现 `DirectAuthProvider`
伪代码如下:
```java
@Component
public class PrivateSsoDirectAuthProvider implements DirectAuthProvider {
@Override
public String providerCode() {
return "private-sso";
}
@Override
public PlatformPrincipal authenticate(DirectAuthRequest request) {
PrivateSsoUser ssoUser = privateSsoClient.verifyPassword(
request.username(),
request.password()
);
return privateSsoIdentityService.resolveOrCreate(ssoUser);
}
}
```
要求:
- 只返回认证成功后的 `PlatformPrincipal`
- 不在这里建立 Session
- 不在这里写 `SecurityContext`
#### 步骤 5实现 `PassiveSessionAuthenticator`
伪代码如下:
```java
@Component
public class PrivateSsoPassiveSessionAuthenticator implements PassiveSessionAuthenticator {
@Override
public String providerCode() {
return "private-sso";
}
@Override
public Optional<PlatformPrincipal> authenticate(HttpServletRequest request) {
return privateSsoClient.verifySession(request)
.map(privateSsoIdentityService::resolveOrCreate);
}
}
```
要求:
- 只消费当前请求已带上的 Cookie 或其他被动凭证
- 不主动重定向到 SSO
- 不在这里自行创建 Session
#### 步骤 6开启配置
私有版部署时启用:
```yaml
skillhub:
auth:
direct:
enabled: true
session-bootstrap:
enabled: true
```
建议:
- 预发环境先只开 direct auth
- passive bootstrap 在确认 Cookie 域和 SameSite 行为可靠后再开启
## 6. 前端最佳实践
### 6.1 推荐的登录页策略
私有版推荐保留当前开源登录页结构,但增加企业 SSO 入口:
- 保留 OAuth 按钮
- 本地账号登录是否保留,由私有版自行决定
- 增加企业 SSO 用户名密码表单,或将现有密码表单切换到 direct auth 兼容接口
- 增加“从企业 SSO 登录”按钮,对应 `session/bootstrap`
推荐优先级:
1. 首先提供明确可见的企业用户名密码登录
2. 其次提供“从企业 SSO 登录”按钮
3. 最后才考虑自动 bootstrap
### 6.2 自动 bootstrap 的使用建议
只有在以下条件同时满足时才建议开启 `SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO=true`
- 已确认浏览器在 `skill.xxx.com` 下能稳定带上 SSO Cookie
- 失败时 UI 不会卡死或重复重试
- 页面只会自动尝试一次
- 前端不会因为自动尝试失败而阻断正常密码登录
如果以上条件不满足,建议只显示一个显式按钮,让用户主动触发。
### 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](/Users/xudongsun/github/skillhub/docs/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、现有业务接口对登录来源无感知
- 文档、配置、测试都完整