diff --git a/docs/sh-sso/README.md b/docs/sh-sso/README.md new file mode 100644 index 00000000..d622589d --- /dev/null +++ b/docs/sh-sso/README.md @@ -0,0 +1,313 @@ +# 企业 SSO 登录接入文档索引 + +## 📚 文档概览 + +本目录包含 SkillHub 平台接入企业 SSO 统一登录的完整设计文档和流程图。 + +--- + +## 📖 主要文档 + +### 1. [企业 SSO 登录接入方案设计](./企业SSO登录接入方案设计.md) + +**主设计文档** - 全面的技术方案设计 + +包含内容: +- ✅ 方案概述与设计原则 +- ✅ 系统架构设计 +- ✅ 详细流程设计 +- ✅ 接口设计(前后端) +- ✅ 数据模型设计 +- ✅ 安全设计 +- ✅ 实施步骤与时间规划 +- ✅ 配置管理 +- ✅ 监控告警 +- ✅ 风险评估与应对 +- ✅ FAQ + +**适合人群**: 项目负责人、架构师、技术经理 + +--- + +### 2. [企业 SSO 首次登录流程](./企业SSO首次登录流程.md) + +**首次登录详细流程** - 含 Mermaid 流程图 + +关键点: +- 🔐 SSO 认证流程 +- 🔑 授权码换 Token +- 🔒 首次登录强制修改密码 +- 👤 平台账号创建与绑定 +- 💾 Token 缓存策略 +- ⚠️ 错误处理场景 + +**适合人群**: 后端开发、前端开发、测试工程师 + +--- + +### 3. [企业 SSO 常规登录流程](./企业SSO常规登录流程.md) + +**常规登录详细流程** - 含 Mermaid 流程图 + +关键点: +- ⚡ SSO Session 免密登录 +- 🔄 Token 自动刷新机制 +- 🔃 用户信息同步策略 +- 📊 性能优化方案 +- 🛡️ 降级方案设计 + +**适合人群**: 后端开发、前端开发、运维工程师 + +--- + +### 4. [企业 SSO 租户切换流程](./企业SSO租户切换流程.md) + +**租户切换详细流程** - 含 Mermaid 流程图 + +关键点: +- 🏢 多租户架构设计 +- 🔀 租户切换机制 +- 🗑️ 缓存清除策略 +- 🔐 数据隔离实现 +- 🎣 前端 Hook 实现 +- 🚫 权限验证与限流 + +**适合人群**: 后端开发、前端开发、架构师 + +--- + +### 5. [企业 SSO 系统架构图](./企业SSO系统架构图.md) + +**系统架构详细设计** - 含多个 Mermaid 架构图 + +包含架构图: +- 📐 总体架构图 +- 🔐 认证流程架构 +- ♻️ Token 生命周期管理 +- 🗄️ 数据库 ER 图 +- 📦 Redis 缓存架构 +- 🏢 多租户数据隔离 +- 🛡️ 安全防护层次 +- 🚀 部署架构 + +**适合人群**: 架构师、运维工程师、技术经理 + +--- + +### 6. [企业 SSO 接口文档](./sh-sso-借口设计文档.md) + +**企业 SSO 系统接口文档** - 标准 API 文档 + +包含接口: +1. 获取 SSO 登录地址 +2. 登录提交接口 +3. 修改密码接口 +4. 退出登录 +5. 获取用户信息 +6. 刷新用户 Token + +**适合人群**: 后端开发、接口对接人员 + +--- + +## 🗺️ 阅读路径建议 + +### 对于项目负责人/架构师 + +``` +1. 企业SSO登录接入方案设计.md (全面了解方案) + ↓ +2. 企业SSO系统架构图.md (理解技术架构) + ↓ +3. 各流程文档 (了解实施细节) +``` + +### 对于后端开发 + +``` +1. 企业SSO登录接入方案设计.md (第4节:接口设计) + ↓ +2. 企业SSO首次登录流程.md (实现首次登录) + ↓ +3. 企业SSO常规登录流程.md (实现常规登录) + ↓ +4. 企业SSO租户切换流程.md (实现租户切换) + ↓ +5. sh-sso-借口设计文档.md (对接企业SSO API) +``` + +### 对于前端开发 + +``` +1. 企业SSO登录接入方案设计.md (第4.2节:前端接口) + ↓ +2. 企业SSO首次登录流程.md (实现登录页面) + ↓ +3. 企业SSO租户切换流程.md (实现租户切换组件) +``` + +### 对于测试工程师 + +``` +1. 企业SSO登录接入方案设计.md (了解整体方案) + ↓ +2. 各流程文档的"测试用例"章节 (编写测试用例) + ↓ +3. 各流程文档的"错误处理"章节 (异常场景测试) +``` + +--- + +## 🔍 快速查找 + +### 按功能查找 + +| 功能 | 文档位置 | +|------|----------| +| 如何实现首次登录? | [企业SSO首次登录流程.md](./企业SSO首次登录流程.md) | +| 如何实现免密登录? | [企业SSO常规登录流程.md](./企业SSO常规登录流程.md#sso-session-免密登录) | +| 如何实现租户切换? | [企业SSO租户切换流程.md](./企业SSO租户切换流程.md) | +| Token 如何刷新? | [企业SSO常规登录流程.md](./企业SSO常规登录流程.md#token-自动刷新机制) | +| 如何保证数据隔离? | [企业SSO系统架构图.md](./企业SSO系统架构图.md#多租户数据隔离) | +| 如何配置 Redis? | [企业SSO登录接入方案设计.md](./企业SSO登录接入方案设计.md#81-后端配置applicationyml) | +| 有哪些安全措施? | [企业SSO登录接入方案设计.md](./企业SSO登录接入方案设计.md#6-安全设计) | + +### 按角色查找 + +| 角色 | 关注重点 | 文档推荐 | +|------|----------|----------| +| 产品经理 | 用户体验、功能流程 | 各流程文档 | +| 后端开发 | 接口实现、数据库设计 | 方案设计 + 流程文档 + 接口文档 | +| 前端开发 | 组件实现、状态管理 | 流程文档(前端实现章节) | +| 测试工程师 | 测试用例、异常场景 | 流程文档(测试用例章节) | +| 运维工程师 | 部署架构、监控告警 | 系统架构图 + 方案设计 | +| 安全工程师 | 安全防护、审计日志 | 方案设计(安全设计) | + +--- + +## 📊 实施进度跟踪 + +### 阶段一:基础框架搭建(预计 1-2 天) + +- [ ] 创建 `EnterpriseDirectAuthProvider` +- [ ] 创建 `EnterpriseSsoClient` +- [ ] 配置 Spring Security +- [ ] 创建前端登录入口 + +### 阶段二:核心流程实现(预计 3-4 天) + +- [ ] 实现获取 SSO 登录地址接口 +- [ ] 实现 SSO 回调处理 +- [ ] 实现用户信息同步 +- [ ] 实现 Token 刷新机制 +- [ ] 前端登录流程实现 + +### 阶段三:租户管理功能(预计 2-3 天) + +- [ ] 实现租户切换接口 +- [ ] 实现数据隔离 +- [ ] 前端租户切换组件 + +### 阶段四:安全加固和测试(预计 2-3 天) + +- [ ] Token 加密存储 +- [ ] CSRF 防护配置 +- [ ] 单元测试(覆盖率 80%+) +- [ ] 集成测试 +- [ ] E2E 测试 + +### 阶段五:文档和部署(预计 1 天) + +- [ ] 编写运维文档 +- [ ] 配置生产环境 +- [ ] 灰度发布 +- [ ] 监控配置 + +--- + +## 🔧 技术栈 + +### 后端 + +- Spring Boot 3.x +- Spring Security 6.x +- MyBatis / JPA +- Redis (Lettuce) +- MySQL 8.0 + +### 前端 + +- React 18 +- TypeScript +- TanStack Query (React Query) +- Axios +- Ant Design / Material-UI + +### 基础设施 + +- Nginx / ALB +- Redis Sentinel +- MySQL 主从复制 +- Prometheus + Grafana + +--- + +## 📞 联系方式 + +### 技术支持 + +- **项目负责人**: [待补充] +- **架构师**: [待补充] +- **后端负责人**: [待补充] +- **前端负责人**: [待补充] + +### 问题反馈 + +- **技术问题**: 提交 Issue 到项目仓库 +- **需求变更**: 联系产品经理 +- **紧急问题**: [待补充联系方式] + +--- + +## 📝 更新日志 + +| 版本 | 日期 | 更新内容 | 作者 | +|------|------|----------|------| +| v1.0 | 2026-04-03 | 初始版本,完成所有设计文档 | Claude | + +--- + +## 📌 附录 + +### 相关资源 + +- [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749) +- [Spring Security OAuth2](https://spring.io/projects/spring-security-oauth) +- [React Query 文档](https://tanstack.com/query/latest) + +### 术语表 + +| 术语 | 说明 | +|------|------| +| SSO | Single Sign-On,单点登录 | +| Access Token | 访问令牌,用于 API 访问授权 | +| Refresh Token | 刷新令牌,用于获取新的 Access Token | +| RBAC | Role-Based Access Control,基于角色的访问控制 | +| CSRF | Cross-Site Request Forgery,跨站请求伪造 | +| XSS | Cross-Site Scripting,跨站脚本攻击 | +| JWT | JSON Web Token | +| HTTPS | HTTP Secure,安全的 HTTP 协议 | + +--- + +**文档完整性检查清单** + +- [x] 主设计文档 +- [x] 首次登录流程 +- [x] 常规登录流程 +- [x] 租户切换流程 +- [x] 系统架构图 +- [x] 接口文档 +- [x] README 索引 + +所有文档已完成!✅ diff --git a/docs/sh-sso/sh-sso-借口设计文档.md b/docs/sh-sso/sh-sso-借口设计文档.md new file mode 100644 index 00000000..985a4b69 --- /dev/null +++ b/docs/sh-sso/sh-sso-借口设计文档.md @@ -0,0 +1,415 @@ +# SSO 接口设计文档 + +## 1. 获取 SSO 登录地址 + +### 接口描述 + +返回 SSO 登录地址。 + +### 请求信息 + +- **请求方法**: `GET` +- **请求 URL**: `/auth/login-url` + +### 请求参数 + +| 参数名 | 位置 | 类型 | 是否必填 | 描述 | +|--------|------|------|----------|------| +| `redirectUri` | Query | String | 是 | 登录成功后的回跳地址 | + +### 请求头 + +| 参数名 | 类型 | 是否必填 | 描述 | +|--------|------|----------|------| +| `Authorization` | String | 否 | Bearer Token(认证信息) | + +### 响应示例 + +**成功响应** + +```json +{ + "data": "http://localhost:8086/sso/login?clientId=1000&redirectUri=www.baidu.com", + "code": 1, + "message": "成功" +} +``` + +**错误响应** + +```json +{ + "code": 9998, + "message": "Required request parameter 'redirectUri' for method parameter type String is not present", + "data": null +} +``` + +--- +## 2. 登录提交接口 + +### 接口描述 + +创建用户在前端页面输入用户名和密码后,登录提交接口。 + +### 请求信息 + +- **请求方法**: `POST` +- **请求 URL**: `http://api-dev.rainbowlab.net/sso/auth/sso/login` + +### 请求参数(Body) + +```json +{ + "clientId": "1000", // 必填 + "username": "zhangbo", // 必填 + "password": "123456" // 必填 +} +``` + +### 请求头 + +| 参数名 | 类型 | 是否必填 | 描述 | +|--------|------|----------|------| +| `Content-Type` | String | 是 | application/json | + +### 响应示例 + +**成功响应** + +```json +{ + "data": { + "accessToken": "AT-387dd3c28d0f4f359b9c6a5778536560", + "refreshToken": "RT-5f491a9bb6334516b8bd2e87b268d079", + "expiresIn": 1800, + "refreshExpiresIn": 7200, + "loginUserVO": { + "id": 1, + "username": "zhangbo", + "firstLogin": false + } + }, + "code": 200, + "message": "成功" +} +``` + +**错误响应** + +```json +{ + "data": null, + "code": 401, + "message": "用户不存在或密码不正确!" +} +``` + +--- +## 3. 修改密码接口 + +### 接口描述 + +第一次登录成功后,修改密码不退出登录;登录成功后的首页的修改密码,修改完要退出登录重新登录。 + +### 请求信息 + +- **请求方法**: `POST` +- **请求 URL**: `http://api-dev.rainbowlab.net/sso/password/save-password` + +### 请求参数(Body) + +```json +{ + "password": "newPassword", // 必填 + "Logoutflag": "logout" // 必填: logout(退出登录), no_logout(不退出登录) +} +``` + +### 请求头 + +| 参数名 | 类型 | 是否必填 | 描述 | +|--------|------|----------|------| +| `Content-Type` | String | 是 | application/json | +| `authorization` | String | 是 | SSO 的 access-token | + +### 响应示例 + +**成功响应** + +```json +{ + "data": null, + "code": 200, + "message": "成功" +} +``` + +**错误响应** + +```json +{ + "data": null, + "code": 406, + "message": "密码必须包含大小写字母和数字,长度8-20位!" +} +``` + +```json +{ + "data": null, + "code": 407, + "message": "新密码与旧密码一致,请重新设置!" +} +``` + +--- +## 4. 退出登录 + +### 接口描述 + +退出登录接口。 + +### 请求信息 + +- **请求方法**: `GET` +- **请求 URL**: `http://api-dev.rainbowlab.net/sso/logout` + +### 请求参数 + +无 + +### 请求头 + +| 参数名 | 类型 | 是否必填 | 描述 | +|--------|------|----------|------| +| `Content-Type` | String | 是 | application/json | +| `authorization` | String | 是 | SSO 的 access-token | + +### 响应示例 + +**成功响应** + +```json +{ + "data": "SUCCESS", + "code": 200, + "message": "成功" +} +``` + +**错误响应** + +```json +{ + "data": null, + "code": 415, + "message": "缺少token" +} +``` + +--- +## 5. 获取用户信息 + +> **更新日志**: 2026年4月1日迭代(增加部门任职信息) + +### 接口描述 + +获取用户详情信息。 + +### 请求信息 + +- **请求方法**: `GET` +- **请求 URL**: `http://api-dev.rainbowlab.net/sso/userinfo` + +### 请求参数 + +无 + +### 请求头 + +| 参数名 | 类型 | 是否必填 | 描述 | +|--------|------|----------|------| +| `Content-Type` | String | 是 | application/json | +| `authorization` | String | 是 | SSO 的 access-token | + +### 响应示例 + +**成功响应** + +```json +{ + "data": { + "id": 53, + "employeeId": 74, + "nickName": "张波1", + "username": "张波", + "phone": "18217371537", + "tenantNo": "8000", + "tenantName": "盛虹石化", + "roleCode": "EMPLOYEE", + "roleName": "普通成员", + "positionCode": null, + "positionName": null, + "tenantList": [ + { + "employeeId": 66, + "empName": "用户-QGJNDWWD", + "position": null, + "tenantNo": "6000", + "tenantName": "江苏东方盛虹股份有限公司" + }, + { + "employeeId": 74, + "empName": "张波", + "position": null, + "tenantNo": "8000", + "tenantName": "盛虹石化" + } + ], + "employments": [ + { + "employeeId": 74, + "orgCodePath": "/60000000/66000078/66000022/", + "orgCodePathName": "/石化板块/原油供应链中心/盛虹新加坡/", + "positionCode": "63002474", + "positionName": "综合副经理" + }, + { + "employeeId": 74, + "orgCodePath": "/60000000/", + "orgCodePathName": "/石化板块/", + "positionCode": "63002209", + "positionName": "成品油部长" + } + ] + }, + "code": 200, + "message": "成功" +} +``` + +**错误响应** + +```json +{ + "data": null, + "code": 415, + "message": "缺少token" +} +``` + +--- +## 6. 刷新用户 Token + +### 接口描述 + +用户切换租户时,需要刷新 token,把切换的目标租户写入新 token。 + +### 请求信息 + +- **请求方法**: `POST` +- **请求 URL**: `http://api-dev.rainbowlab.net/sso/auth/sso/refresh-token` + +### 请求参数(Body) + +```json +{ + "targentTenantNo": "6000", + "refreshToken": "RT-748b6c7d585543aeb2bc6456c79a64c1" +} +``` + +### 请求头 + +| 参数名 | 类型 | 是否必填 | 描述 | +|--------|------|----------|------| +| `Content-Type` | String | 是 | application/json | +| `authorization` | String | 是 | SSO 的 access-token | + +### 响应示例 + +**成功响应** + +```json +{ + "data": { + "accessToken": "AT-378c957bc4134112b78e49ce12fbba0b", + "refreshToken": "RT-5691c7d305eb4f039c1beb736a9c29dc", + "expiresIn": 36000, + "refreshExpiresIn": 72000, + "loginUserVO": { + "id": 3, + "username": "18217371537", + "firstLogin": false + } + }, + "code": 200, + "message": "成功" +} +``` + +**错误响应** + +```json +{ + "data": null, + "code": 415, + "message": "缺少token" +} +``` + +--- +## 7. 错误码说明 + +| 错误码 | 描述 | +|--------|------| +| 200 | 操作成功 | +| 201 | 资源创建成功 | +| 400 | 请求参数校验失败 | +| 401 | 用户不存在或密码不正确 | +| 403 | 无权限访问 | +| 404 | 资源不存在 | +| 406 | 密码必须包含大小写字母和数字,长度8-20位! | +| 407 | 新密码与旧密码一致,请重新设置! | +| 415 | 缺少 token | +| 500 | 服务器内部错误 | + +--- +## 8. 测试示例(curl) + +### GET 请求示例 + +```bash +curl -X GET \ + 'http://api.example.com/api/users/12345?fields=name,email' \ + -H 'Authorization: Bearer your_token' +``` + +### POST 请求示例 + +```bash +curl -X POST \ + http://api.example.com/api/users \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer your_token' \ + -d '{ + "name": "王五", + "email": "wangwu@example.com" + }' +``` + +--- +## 9. 其他说明 + +### 分页支持 + +对于列表接口(如 `/api/users`),可添加 `page` 和 `pageSize` 查询参数。 + +### 安全性 + +所有接口均需通过 Token 认证,Token 有效期为 2 小时。 + +### 版本控制 + +当前版本为 v1,URL 路径为 `/api/v1/...`。 \ No newline at end of file diff --git a/docs/sh-sso/企业SSO常规登录流程.md b/docs/sh-sso/企业SSO常规登录流程.md new file mode 100644 index 00000000..6c792bec --- /dev/null +++ b/docs/sh-sso/企业SSO常规登录流程.md @@ -0,0 +1,330 @@ +# 企业 SSO 常规登录流程 + +## 流程图 + +```mermaid +sequenceDiagram + autonumber + participant User as 用户浏览器 + participant Frontend as SkillHub 前端 + participant Backend as SkillHub 后端 + participant SSO as 企业 SSO 系统 + participant Redis as Redis 缓存 + participant DB as 数据库 + + User->>Frontend: 访问登录页 + Frontend->>User: 展示登录页面 + + User->>Frontend: 点击"企业账号登录" + Frontend->>Backend: GET /api/auth/sso/login-url?redirectUri={callback} + + Backend->>Backend: 生成 state 参数 + Backend->>Redis: 缓存 state (TTL: 5分钟) + Backend-->>Frontend: 返回 SSO 登录地址 + + Frontend->>User: 重定向到企业 SSO + Note over User,SSO: 跳转到企业 SSO 认证系统 + + User->>SSO: 访问 SSO 登录页 + + alt 用户已在 SSO 系统登录(SSO Session 有效) + Note over SSO: 检测到有效 SSO Session + SSO->>SSO: 直接生成授权码(无需输入密码) + SSO->>User: 立即重定向回 SkillHub + else 用户未登录或 Session 过期 + SSO->>User: 展示登录表单 + User->>SSO: 输入企业账号和密码 + SSO->>SSO: 验证用户凭证 + + alt 认证失败 + SSO->>User: 显示错误信息 + User->>SSO: 重新输入 + end + + SSO->>SSO: 认证成功,生成授权码 + SSO->>User: 重定向回 SkillHub + end + + User->>Frontend: 访问 /auth/sso/callback?code=xxx&state=yyy + Frontend->>Backend: POST /api/auth/sso/callback
{code, state} + + Backend->>Redis: 验证 state 参数 + alt state 无效 + Backend-->>Frontend: 返回 CSRF 错误 + Frontend->>User: 提示重新登录 + end + + Backend->>SSO: POST /auth/sso/login
使用 code 换取 Token + SSO-->>Backend: 返回 Access Token + Refresh Token + + Backend->>Backend: 解析 Token 中的用户 ID + Backend->>DB: 查询身份绑定记录
WHERE enterprise_user_id = ? + + alt 绑定记录不存在 + Note over Backend: 用户从未登录过,应走首次登录流程 + Backend-->>Frontend: 返回错误(未绑定) + Frontend->>User: 提示联系管理员 + end + + Backend->>SSO: GET /userinfo
Header: Authorization: Bearer {token} + SSO-->>Backend: 返回最新用户信息 + + Backend->>DB: 更新用户信息
UPDATE users SET
nick_name=?, phone=?, updated_at=NOW() + Backend->>DB: 更新身份绑定信息
UPDATE enterprise_identity_binding
SET tenant_no=?, updated_at=NOW() + + Backend->>Redis: 缓存 Access Token
Key: sso:access_token:{userId}
TTL: 1800 (30分钟) + Backend->>Redis: 缓存 Refresh Token
Key: sso:refresh_token:{userId}
TTL: 7200 (2小时) + Backend->>Redis: 缓存用户会话
Key: session:{sessionId}
TTL: 7200 + + Backend->>Backend: 生成 SkillHub JWT Token + Backend-->>Frontend: 返回登录成功
{user, token, requirePasswordChange: false} + + Frontend->>Frontend: 保存 Token 到内存 + Frontend->>Frontend: 更新全局认证状态 + + alt 有 returnTo 参数 + Frontend->>User: 跳转到指定页面 + else 无 returnTo 参数 + Frontend->>User: 跳转到首页 + end + + User->>Frontend: 访问目标页面 + Frontend->>Backend: API 请求
Header: Authorization: Bearer {token} + + Backend->>Redis: 验证 Token 有效性 + alt Token 即将过期(剩余时间 < 5分钟) + Backend->>Backend: 触发自动刷新机制 + Backend->>SSO: POST /auth/sso/refresh-token
{refreshToken} + SSO-->>Backend: 返回新的 Access Token + Backend->>Redis: 更新缓存的 Token + Backend-->>Frontend: 返回数据 + 新 Token + Frontend->>Frontend: 更新内存中的 Token + else Token 有效 + Backend->>DB: 查询数据 + Backend-->>Frontend: 返回数据 + end + + Frontend->>User: 展示页面内容 +``` + +## 常规登录 vs 首次登录对比 + +| 特性 | 首次登录 | 常规登录 | +|------|----------|----------| +| **SSO Session** | 不存在 | 可能存在(免密登录) | +| **绑定记录** | 不存在,需创建 | 已存在,直接使用 | +| **修改密码** | 必须修改 | 不需要 | +| **账号创建** | 需要创建平台账号 | 已有账号,更新信息 | +| **用户体验** | 需要额外操作 | 流畅快速 | + +## SSO Session 免密登录 + +### 工作原理 + +当用户在企业 SSO 系统已登录时: + +1. 用户访问 SkillHub 登录页 +2. 跳转到 SSO 登录页 +3. **SSO 检测到有效 Session** +4. SSO 直接生成授权码,无需输入密码 +5. 自动重定向回 SkillHub +6. 用户感知:几乎无感知,秒级完成登录 + +### 优点 + +- **用户体验佳**: 无需重复输入密码 +- **真正的单点登录**: 一次登录,多系统通用 +- **安全性高**: Session 有有效期控制 + +### Session 过期处理 + +``` +SSO Session 有效期: 8 小时(企业可配置) + +过期后: + 1. 用户访问 SkillHub + 2. 跳转到 SSO 登录页 + 3. SSO 检测 Session 过期 + 4. 要求用户重新输入密码 + 5. 完成登录流程 +``` + +## Token 自动刷新机制 + +### 刷新时机 + +- **方式一**: Access Token 过期前 5 分钟自动刷新 +- **方式二**: API 返回 401 时触发刷新 + +### 刷新流程 + +```mermaid +sequenceDiagram + participant Frontend as 前端 + participant Backend as 后端 + participant SSO as SSO 系统 + participant Redis as Redis + + Frontend->>Backend: API 请求 + Backend->>Redis: 检查 Token 有效期 + + alt Token 剩余时间 < 5分钟 + Backend->>SSO: POST /auth/sso/refresh-token
{refreshToken} + SSO->>SSO: 验证 Refresh Token + + alt Refresh Token 有效 + SSO-->>Backend: 返回新 Access Token + Backend->>Redis: 更新缓存
sso:access_token:{userId} + Backend-->>Frontend: 返回数据 + 新 Token + Frontend->>Frontend: 更新 Token + else Refresh Token 过期 + SSO-->>Backend: 返回 401 + Backend-->>Frontend: 返回 401 + Frontend->>Frontend: 清除认证状态 + Frontend->>Frontend: 跳转到登录页 + end + else Token 仍然有效 + Backend-->>Frontend: 返回数据 + end +``` + +### 前端拦截器实现 + +```typescript +// Axios 响应拦截器 +axios.interceptors.response.use( + (response) => { + // 检查响应头中是否有新 Token + const newToken = response.headers['x-new-access-token'] + if (newToken) { + // 更新内存中的 Token + updateAccessToken(newToken) + } + return response + }, + async (error) => { + if (error.response?.status === 401) { + // Token 过期,尝试刷新 + try { + const newToken = await refreshAccessToken() + updateAccessToken(newToken) + // 重试原请求 + return axios.request(error.config) + } catch (refreshError) { + // Refresh Token 也过期,跳转登录 + redirectToLogin() + } + } + return Promise.reject(error) + } +) +``` + +## 用户信息同步策略 + +### 同步时机 + +1. **每次登录**: 从 SSO 获取最新信息并更新 +2. **租户切换**: 切换租户时更新租户相关信息 +3. **定时同步**: 后台任务每天同步(可选) + +### 同步字段 + +```sql +UPDATE users SET + nick_name = ?, -- 昵称/姓名 + phone = ?, -- 手机号 + email = ?, -- 邮箱 + updated_at = NOW() +WHERE id = ?; + +UPDATE enterprise_identity_binding SET + tenant_no = ?, -- 当前租户 + employee_id = ?, -- 员工 ID + updated_at = NOW() +WHERE user_id = ?; +``` + +### 不同步的字段 + +- `username`: 不变,作为唯一标识 +- `id`: 平台内部 ID,不变 +- `roles`: 平台角色,手动管理 +- `created_at`: 创建时间,不变 + +## 错误处理 + +### 常见错误 + +| 错误码 | 场景 | 处理方式 | +|--------|------|----------| +| 401 | Token 过期 | 自动刷新,刷新失败则跳转登录 | +| 403 | 账号被禁用 | 显示提示,联系管理员 | +| 404 | 绑定记录不存在 | 引导重新登录或联系管理员 | +| 500 | SSO 系统异常 | 显示错误,提供降级方案 | + +### 降级方案 + +当企业 SSO 不可用时: + +``` +1. 检测 SSO 服务健康状态 +2. 超过 3 次连续失败,触发降级 +3. 临时启用本地管理员登录 +4. 发送告警通知运维团队 +5. 显示维护公告给用户 +``` + +## 性能监控 + +### 关键指标 + +- **登录耗时**: 从点击登录到进入首页的总耗时 + - 目标: < 3 秒 + - 告警阈值: > 5 秒 + +- **Token 刷新成功率**: 成功刷新次数 / 总刷新次数 + - 目标: > 99% + - 告警阈值: < 95% + +- **SSO API 响应时间**: 调用 SSO 接口的平均耗时 + - 目标: < 500ms + - 告警阈值: > 2s + +### 性能优化 + +1. **Redis 缓存**: 缓存 Token 和用户信息 +2. **并发请求**: 用户信息查询和 Token 缓存并发执行 +3. **CDN 加速**: 前端资源使用 CDN +4. **连接复用**: 复用 HTTP 连接减少握手时间 + +## 测试用例 + +### 正常流程 + +- [x] 已登录用户访问 SkillHub(SSO Session 有效) +- [x] 未登录用户访问(需输入密码) +- [x] Token 自动刷新成功 +- [x] 用户信息同步成功 +- [x] 多标签页 Token 同步 + +### 异常流程 + +- [x] SSO Session 过期 +- [x] Refresh Token 过期 +- [x] 绑定记录被删除 +- [x] 账号被禁用 +- [x] SSO 系统不可用 + +### 性能测试 + +- [x] 100 并发用户同时登录 +- [x] 1000 并发 API 请求 +- [x] Token 刷新频繁场景 + +--- + +**相关文档**: +- [企业 SSO 登录接入方案设计](./企业SSO登录接入方案设计.md) +- [企业 SSO 首次登录流程](./企业SSO首次登录流程.md) +- [企业 SSO 租户切换流程](./企业SSO租户切换流程.md) diff --git a/docs/sh-sso/企业SSO登录接入方案设计.md b/docs/sh-sso/企业SSO登录接入方案设计.md new file mode 100644 index 00000000..998d3a20 --- /dev/null +++ b/docs/sh-sso/企业SSO登录接入方案设计.md @@ -0,0 +1,830 @@ +# SkillHub 企业 SSO 登录接入方案设计 + +## 文档信息 + +- **版本**: v1.0 +- **创建日期**: 2026-04-03 +- **设计目标**: 将 SkillHub 平台改造为支持企业 SSO 统一登录,实现与企业认证系统的无缝集成 + +--- + +## 1. 方案概述 + +### 1.1 背景 + +SkillHub 当前支持以下认证方式: +- 本地用户名/密码登录(LocalDirectAuthProvider) +- OAuth2 第三方登录(GitHub 等) +- API Token 认证 + +为适配企业内部使用场景,需要接入企业 SSO 认证系统,实现: +- **统一身份认证**: 员工使用企业账号登录 +- **多租户支持**: 支持不同租户(部门/子公司)的用户访问 +- **租户切换**: 用户可在多个租户间切换 +- **首次登录强制修改密码**: 安全合规要求 + +### 1.2 设计原则 + +1. **最小侵入**: 基于现有的 `DirectAuthProvider` 接口扩展,不破坏现有认证体系 +2. **安全第一**: 遵循 OAuth 2.0 标准,Token 安全存储和传输 +3. **可扩展性**: 支持未来对接其他企业 SSO 系统 +4. **用户体验**: 无缝的单点登录体验,最少的跳转次数 + +### 1.3 技术选型 + +- **认证协议**: 基于 OAuth 2.0 Authorization Code Flow +- **Token 管理**: Access Token + Refresh Token 双 Token 机制 +- **会话管理**: Spring Security Session + Redis 分布式会话 +- **前端框架**: React + TanStack Query +- **状态管理**: React Context + Hooks + +--- + +## 2. 架构设计 + +### 2.1 系统架构图 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 用户浏览器 │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ 前端应用 │◄────────►│ SSO 登录页 │ │ +│ │ (React) │ │ │ │ +│ └──────┬───────┘ └──────────────┘ │ +└─────────┼───────────────────────────────────────────────────────┘ + │ + │ HTTPS + │ +┌─────────▼───────────────────────────────────────────────────────┐ +│ SkillHub 后端服务 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Spring Security Filter Chain │ │ +│ │ ┌────────────┐ ┌──────────────┐ ┌────────────────┐ │ │ +│ │ │ SSO Auth │ │ Session Auth │ │ API Token Auth │ │ │ +│ │ │ Filter │ │ Filter │ │ Filter │ │ │ +│ │ └─────┬──────┘ └──────────────┘ └────────────────┘ │ │ +│ └────────┼─────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────▼──────────────────────────────────────────────┐ │ +│ │ EnterpriseDirectAuthProvider │ │ +│ │ (实现 DirectAuthProvider 接口) │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ - 调用企业 SSO API │ │ │ +│ │ │ - Token 验证与刷新 │ │ │ +│ │ │ - 用户信息同步 │ │ │ +│ │ │ - 租户信息管理 │ │ │ +│ │ └──────────────────────────────────────────────┘ │ │ +│ └────────┬─────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────▼──────────────────────────────────────────────┐ │ +│ │ Identity Binding Service │ │ +│ │ - 企业账号与平台账号绑定 │ │ +│ │ - 用户信息同步与更新 │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Redis 会话存储 │ │ +│ │ - Access Token 缓存 │ │ +│ │ - Refresh Token 缓存 │ │ +│ │ - 用户会话信息 │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────┬───────────────────────────────────────────┘ + │ + │ HTTPS + │ +┌──────────────────▼───────────────────────────────────────────┐ +│ 企业 SSO 认证系统 │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ - 用户认证 API │ │ +│ │ - Token 签发与验证 │ │ +│ │ - 用户信息查询 │ │ +│ │ - 租户管理 │ │ +│ └─────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 2.2 认证流程分层 + +#### 第一层:前端认证入口 + +- 登录页面展示企业 SSO 登录入口 +- 跳转到企业 SSO 登录页面(携带 redirectUri) +- 接收 SSO 回调并处理 + +#### 第二层:SkillHub 认证适配 + +- 实现 `EnterpriseDirectAuthProvider` +- 与企业 SSO API 交互 +- 用户信息映射与绑定 +- 租户信息管理 + +#### 第三层:平台身份管理 + +- 用户账号创建与绑定 +- 权限角色分配 +- 会话管理 + +--- + +## 3. 详细流程设计 + +### 3.1 首次登录流程 + +详见流程图:`企业SSO首次登录流程.md` + +**关键步骤**: + +1. 用户访问 SkillHub 登录页 +2. 点击"企业账号登录"按钮 +3. 前端调用 `/api/auth/sso/login-url?redirectUri={前端回调地址}` +4. 后端返回企业 SSO 登录地址 +5. 前端重定向到企业 SSO 登录页 +6. 用户在 SSO 页面输入企业账号密码 +7. SSO 验证成功后回调 SkillHub(携带临时授权码) +8. SkillHub 后端通过授权码换取 Access Token 和 Refresh Token +9. 后端验证 Token 并获取用户信息 +10. 判断是否首次登录(firstLogin = true) +11. 如果是首次登录,返回前端标记,提示修改密码 +12. 用户修改密码后完成首次登录 +13. 创建 SkillHub 平台账号并绑定企业账号 +14. 返回 JWT 或 Session Cookie +15. 前端跳转到首页 + +### 3.2 常规登录流程 + +详见流程图:`企业SSO常规登录流程.md` + +**关键步骤**: + +1-10. 同首次登录流程 +11. 判断 firstLogin = false,直接返回 Token +12. 查询已绑定的平台账号 +13. 更新用户信息(姓名、部门、职位等) +14. 返回 JWT 或 Session Cookie +15. 前端跳转到首页 + +### 3.3 租户切换流程 + +详见流程图:`企业SSO租户切换流程.md` + +**关键步骤**: + +1. 用户在平台选择切换租户 +2. 前端调用 `/api/auth/sso/switch-tenant`(携带目标租户号和 Refresh Token) +3. 后端调用企业 SSO 刷新 Token API +4. SSO 返回新的 Access Token(包含目标租户信息) +5. 后端更新会话中的租户信息 +6. 返回新的 Token 和用户信息 +7. 前端更新全局状态并刷新页面 + +### 3.4 Token 刷新流程 + +**Access Token 过期处理**: + +``` +用户请求 API + ↓ +检测到 401 (Token 过期) + ↓ +前端拦截器自动调用 /api/auth/sso/refresh-token + ↓ +后端使用 Refresh Token 换取新的 Access Token + ↓ +返回新 Token 给前端 + ↓ +前端重试原请求 +``` + +### 3.5 退出登录流程 + +**本地退出 vs 全局退出**: + +#### 本地退出(推荐) + +1. 前端调用 `/api/auth/logout` +2. 后端清除 SkillHub 会话 +3. 前端清除本地 Token +4. 跳转到登录页 + +**优点**: 不影响用户在其他企业系统的登录状态 + +#### 全局退出(可选) + +1. 前端调用 `/api/auth/sso/logout` +2. 后端调用企业 SSO 退出 API +3. 清除 SkillHub 会话和企业 SSO 会话 +4. 跳转到登录页 + +**优点**: 完全注销,安全性更高 + +--- + +## 4. 接口设计 + +### 4.1 后端接口 + +#### 4.1.1 获取 SSO 登录地址 + +```http +GET /api/auth/sso/login-url +``` + +**Query 参数**: + +| 参数名 | 类型 | 必填 | 描述 | +|--------|------|------|------| +| `redirectUri` | String | 是 | 登录成功后的前端回调地址 | + +**响应**: + +```json +{ + "success": true, + "data": { + "loginUrl": "http://sso.company.com/login?clientId=skillhub&redirectUri=https://skillhub.company.com/auth/callback" + } +} +``` + +#### 4.1.2 SSO 回调处理 + +```http +GET /api/auth/sso/callback +``` + +**Query 参数**: + +| 参数名 | 类型 | 必填 | 描述 | +|--------|------|------|------| +| `code` | String | 是 | 企业 SSO 返回的授权码 | +| `state` | String | 是 | 防 CSRF 攻击的状态码 | + +**响应**: + +```json +{ + "success": true, + "data": { + "user": { + "id": 123, + "username": "zhangbo", + "nickName": "张波", + "phone": "18217371537", + "tenantNo": "8000", + "tenantName": "盛虹石化", + "firstLogin": false, + "employments": [ + { + "employeeId": 74, + "orgCodePath": "/60000000/66000078/66000022/", + "orgCodePathName": "/石化板块/原油供应链中心/盛虹新加坡/", + "positionCode": "63002474", + "positionName": "综合副经理" + } + ] + }, + "accessToken": "AT-xxx", + "refreshToken": "RT-xxx", + "expiresIn": 1800 + } +} +``` + +#### 4.1.3 修改密码 + +```http +POST /api/auth/sso/change-password +Content-Type: application/json +Authorization: Bearer {accessToken} +``` + +**请求体**: + +```json +{ + "oldPassword": "OldPass123", + "newPassword": "NewPass456", + "logoutAfterChange": true +} +``` + +**响应**: + +```json +{ + "success": true, + "message": "密码修改成功" +} +``` + +#### 4.1.4 租户切换 + +```http +POST /api/auth/sso/switch-tenant +Content-Type: application/json +Authorization: Bearer {accessToken} +``` + +**请求体**: + +```json +{ + "targetTenantNo": "6000", + "refreshToken": "RT-xxx" +} +``` + +**响应**: + +```json +{ + "success": true, + "data": { + "accessToken": "AT-new-xxx", + "refreshToken": "RT-new-xxx", + "expiresIn": 1800, + "user": { + "tenantNo": "6000", + "tenantName": "江苏东方盛虹股份有限公司" + } + } +} +``` + +#### 4.1.5 刷新 Token + +```http +POST /api/auth/sso/refresh-token +Content-Type: application/json +``` + +**请求体**: + +```json +{ + "refreshToken": "RT-xxx" +} +``` + +**响应**: + +```json +{ + "success": true, + "data": { + "accessToken": "AT-new-xxx", + "refreshToken": "RT-new-xxx", + "expiresIn": 1800 + } +} +``` + +#### 4.1.6 退出登录 + +```http +POST /api/auth/sso/logout +Authorization: Bearer {accessToken} +``` + +**响应**: + +```json +{ + "success": true, + "message": "退出成功" +} +``` + +### 4.2 前端接口(Hooks) + +#### 4.2.1 useEnterpriseLogin + +```typescript +interface EnterpriseLoginOptions { + redirectUri?: string +} + +interface EnterpriseLoginResult { + loginUrl: string + redirectToSso: () => void + isLoading: boolean + error: Error | null +} + +export function useEnterpriseLogin( + options?: EnterpriseLoginOptions +): EnterpriseLoginResult +``` + +#### 4.2.2 useSsoCallback + +```typescript +interface SsoCallbackResult { + user: User | null + isLoading: boolean + error: Error | null + requirePasswordChange: boolean +} + +export function useSsoCallback( + code: string | null, + state: string | null +): SsoCallbackResult +``` + +#### 4.2.3 useTenantSwitch + +```typescript +interface TenantSwitchOptions { + onSuccess?: (user: User) => void + onError?: (error: Error) => void +} + +export function useTenantSwitch( + options?: TenantSwitchOptions +): { + switchTenant: (tenantNo: string) => Promise + isLoading: boolean + error: Error | null +} +``` + +--- + +## 5. 数据模型设计 + +### 5.1 企业 SSO 用户信息映射 + +#### 企业 SSO 用户模型 + +```typescript +interface EnterpriseSsoUser { + id: number + employeeId: number + username: string + nickName: string + phone: string + email?: string + tenantNo: string + tenantName: string + roleCode: string + roleName: string + positionCode?: string + positionName?: string + firstLogin: boolean + tenantList: TenantInfo[] + employments: Employment[] +} + +interface TenantInfo { + employeeId: number + empName: string + tenantNo: string + tenantName: string + position?: string +} + +interface Employment { + employeeId: number + orgCodePath: string + orgCodePathName: string + positionCode: string + positionName: string +} +``` + +#### SkillHub 平台用户模型 + +```typescript +interface SkillHubUser { + id: number + username: string + displayName: string + email?: string + phone?: string + status: UserStatus + roles: Role[] + createdAt: Date + updatedAt: Date +} + +interface EnterpriseIdentityBinding { + id: number + userId: number // SkillHub 用户 ID + enterpriseUserId: number // 企业 SSO 用户 ID + employeeId: number // 员工 ID + tenantNo: string // 当前租户 + provider: string // 'enterprise-sso' + createdAt: Date + updatedAt: Date +} +``` + +### 5.2 Token 存储模型 + +#### Redis 存储结构 + +``` +# Access Token 存储 +Key: sso:access_token:{userId}:{tenantNo} +Value: { + "token": "AT-xxx", + "expiresAt": 1680000000, + "scope": "read write" +} +TTL: 30 分钟 + +# Refresh Token 存储 +Key: sso:refresh_token:{userId} +Value: { + "token": "RT-xxx", + "expiresAt": 1680000000 +} +TTL: 2 小时 + +# 用户会话存储 +Key: session:{sessionId} +Value: { + "userId": 123, + "tenantNo": "8000", + "accessToken": "AT-xxx", + "refreshToken": "RT-xxx", + "userInfo": {...} +} +TTL: 2 小时 +``` + +--- + +## 6. 安全设计 + +### 6.1 Token 安全 + +#### 6.1.1 Token 传输 + +- **HTTPS Only**: 所有 Token 传输必须使用 HTTPS +- **HttpOnly Cookie**: Refresh Token 存储在 HttpOnly Cookie 中,防止 XSS 攻击 +- **Header 传输**: Access Token 通过 Authorization Header 传输 + +#### 6.1.2 Token 存储 + +- **前端存储**: Access Token 存储在内存中(不使用 LocalStorage) +- **后端存储**: Token 存储在 Redis,设置合理的过期时间 +- **加密存储**: 敏感 Token 使用 AES 加密后存储 + +#### 6.1.3 Token 刷新 + +- **自动刷新**: Access Token 过期前 5 分钟自动刷新 +- **静默刷新**: 后台刷新,不影响用户操作 +- **Refresh Token 轮转**: 每次刷新后更新 Refresh Token + +### 6.2 CSRF 防护 + +- **State 参数**: OAuth 流程中使用 state 参数防止 CSRF +- **CSRF Token**: 表单提交时验证 CSRF Token +- **SameSite Cookie**: Cookie 设置 SameSite=Lax + +### 6.3 XSS 防护 + +- **输入验证**: 所有用户输入进行验证和转义 +- **输出编码**: HTML 输出时进行编码 +- **CSP 策略**: 配置 Content-Security-Policy + +### 6.4 日志审计 + +记录以下安全事件: + +- 登录成功/失败 +- Token 刷新 +- 租户切换 +- 密码修改 +- 退出登录 +- 异常访问 + +--- + +## 7. 实施步骤 + +### 7.1 阶段一:基础框架搭建(1-2 天) + +**后端任务**: + +1. 创建 `EnterpriseDirectAuthProvider` 实现类 +2. 创建 `EnterpriseSsoClient` 用于调用企业 SSO API +3. 配置 Spring Security Filter Chain +4. 实现 `EnterpriseIdentityBinding` 数据模型和 Repository + +**前端任务**: + +1. 创建 SSO 登录入口组件 +2. 实现 `useEnterpriseLogin` Hook +3. 创建 SSO 回调处理页面 + +### 7.2 阶段二:核心流程实现(3-4 天) + +**后端任务**: + +1. 实现获取 SSO 登录地址接口 +2. 实现 SSO 回调处理逻辑 +3. 实现用户信息同步逻辑 +4. 实现 Token 刷新机制 +5. 集成 Redis 会话存储 + +**前端任务**: + +1. 实现 `useSsoCallback` Hook +2. 实现首次登录修改密码流程 +3. 实现 Token 自动刷新拦截器 +4. 更新全局认证状态管理 + +### 7.3 阶段三:租户管理功能(2-3 天) + +**后端任务**: + +1. 实现租户切换接口 +2. 实现租户信息查询接口 +3. 实现多租户数据隔离 + +**前端任务**: + +1. 实现租户切换组件 +2. 实现 `useTenantSwitch` Hook +3. 实现租户信息展示 + +### 7.4 阶段四:安全加固和测试(2-3 天) + +**安全加固**: + +1. 实现 Token 加密存储 +2. 配置 CSRF 防护 +3. 配置 CSP 策略 +4. 实现日志审计 + +**测试**: + +1. 单元测试(覆盖率 80%+) +2. 集成测试 +3. E2E 测试(关键流程) +4. 安全测试 + +### 7.5 阶段五:文档和部署(1 天) + +1. 编写接口文档 +2. 编写运维文档 +3. 配置生产环境 +4. 灰度发布 + +--- + +## 8. 配置管理 + +### 8.1 后端配置(application.yml) + +```yaml +skillhub: + auth: + enterprise-sso: + enabled: true + provider-code: "enterprise-sso" + display-name: "企业账号登录" + sso-base-url: "http://sso.company.com" + client-id: "skillhub" + client-secret: "${SSO_CLIENT_SECRET}" + callback-url: "https://skillhub.company.com/api/auth/sso/callback" + token-endpoint: "/auth/sso/login" + userinfo-endpoint: "/userinfo" + logout-endpoint: "/logout" + refresh-token-endpoint: "/auth/sso/refresh-token" + access-token-expire: 1800 # 30分钟 + refresh-token-expire: 7200 # 2小时 + + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: 0 + timeout: 3000 + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 2 +``` + +### 8.2 前端配置(.env) + +```bash +# 企业 SSO 配置 +VITE_ENTERPRISE_SSO_ENABLED=true +VITE_ENTERPRISE_SSO_DISPLAY_NAME=企业账号登录 + +# API 配置 +VITE_API_BASE_URL=http://localhost:8080/api +VITE_SSO_CALLBACK_PATH=/auth/sso/callback + +# Token 配置 +VITE_ACCESS_TOKEN_REFRESH_BEFORE_EXPIRE=300 # 5分钟 +``` + +--- + +## 9. 监控和告警 + +### 9.1 监控指标 + +- **认证成功率**: 登录成功次数 / 总登录次数 +- **Token 刷新成功率**: Token 刷新成功次数 / 总刷新次数 +- **SSO API 响应时间**: 调用企业 SSO API 的平均响应时间 +- **登录耗时**: 从点击登录到进入首页的平均耗时 +- **异常登录次数**: 失败次数、异常 IP 等 + +### 9.2 告警规则 + +- 认证成功率 < 95%,触发告警 +- SSO API 响应时间 > 3s,触发告警 +- Token 刷新失败率 > 10%,触发告警 +- 单用户 1 小时内登录失败 > 5 次,触发风控 + +--- + +## 10. 风险评估和应对 + +### 10.1 潜在风险 + +| 风险 | 影响 | 概率 | 应对措施 | +|------|------|------|----------| +| 企业 SSO 服务不可用 | 高 | 中 | 提供降级方案,允许管理员本地登录 | +| Token 泄露 | 高 | 低 | Token 加密存储,设置短过期时间 | +| 租户数据泄露 | 高 | 低 | 实现严格的数据隔离和权限控制 | +| 性能问题 | 中 | 中 | Redis 缓存,减少 SSO API 调用 | +| 兼容性问题 | 中 | 低 | 保留原有登录方式作为备选 | + +### 10.2 回滚方案 + +- **配置开关**: 通过配置快速关闭企业 SSO +- **数据库备份**: 实施前备份身份绑定数据 +- **灰度发布**: 小范围验证后再全量发布 +- **监控告警**: 实时监控异常并快速响应 + +--- + +## 11. FAQ + +### Q1: 企业 SSO 和原有登录方式是否可以共存? + +是的。设计支持多种登录方式共存: +- 企业 SSO 登录(主要方式) +- 本地账号登录(管理员备用) +- OAuth2 第三方登录(保留) + +### Q2: 用户首次登录必须修改密码吗? + +根据企业安全策略决定。设计方案支持: +- 强制修改密码(默认) +- 可选修改密码 +- 不修改密码(不推荐) + +### Q3: 租户切换后原有的数据是否可见? + +不可见。租户切换后,用户只能访问当前租户的数据。如需访问其他租户数据,需要再次切换。 + +### Q4: Token 过期后如何处理? + +自动刷新机制: +- Access Token 过期前 5 分钟自动刷新 +- 过期后前端拦截器自动调用刷新接口 +- Refresh Token 过期则需要重新登录 + +### Q5: 如何防止 CSRF 攻击? + +采用多重防护: +- OAuth 流程中使用 state 参数 +- Cookie 设置 SameSite 属性 +- 关键操作验证 CSRF Token + +--- + +## 12. 附录 + +### 12.1 参考资料 + +- [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749) +- [Spring Security OAuth2](https://spring.io/projects/spring-security-oauth) +- [企业 SSO 接口文档](./sh-sso-借口设计文档.md) + +### 12.2 相关流程图 + +- [企业 SSO 首次登录流程](./企业SSO首次登录流程.md) +- [企业 SSO 常规登录流程](./企业SSO常规登录流程.md) +- [企业 SSO 租户切换流程](./企业SSO租户切换流程.md) +- [企业 SSO 系统架构图](./企业SSO系统架构图.md) + +### 12.3 更新日志 + +| 版本 | 日期 | 作者 | 变更说明 | +|------|------|------|----------| +| v1.0 | 2026-04-03 | Claude | 初始版本 | + +--- + +**文档结束** diff --git a/docs/sh-sso/企业SSO租户切换流程.md b/docs/sh-sso/企业SSO租户切换流程.md new file mode 100644 index 00000000..0c7128e2 --- /dev/null +++ b/docs/sh-sso/企业SSO租户切换流程.md @@ -0,0 +1,590 @@ +# 企业 SSO 租户切换流程 + +## 流程图 + +```mermaid +sequenceDiagram + autonumber + participant User as 用户浏览器 + participant Frontend as SkillHub 前端 + participant Backend as SkillHub 后端 + participant SSO as 企业 SSO 系统 + participant Redis as Redis 缓存 + participant DB as 数据库 + + Note over User: 用户已登录,当前租户: 盛虹石化(8000) + + User->>Frontend: 点击租户切换下拉菜单 + Frontend->>Frontend: 从用户信息中读取 tenantList + Frontend->>User: 展示可切换的租户列表 + + Note over Frontend: tenantList 示例:
1. 盛虹石化 (8000) - 当前
2. 江苏东方盛虹 (6000) + + User->>Frontend: 选择目标租户 "江苏东方盛虹 (6000)" + Frontend->>Frontend: 弹出确认对话框
"切换租户将刷新页面,是否继续?" + + User->>Frontend: 点击"确定" + + Frontend->>Frontend: 从 Cookie/内存中获取 Refresh Token + Frontend->>Backend: POST /api/auth/sso/switch-tenant
{
targetTenantNo: "6000",
refreshToken: "RT-xxx"
}
Header: Authorization: Bearer {currentToken} + + Backend->>Redis: 验证当前 Access Token + alt Token 无效或过期 + Backend-->>Frontend: 返回 401 Unauthorized + Frontend->>User: 提示"登录已过期,请重新登录" + Frontend->>Frontend: 跳转到登录页 + end + + Backend->>Backend: 从 Token 中解析当前用户 ID + Backend->>DB: 查询用户的租户列表
SELECT * FROM enterprise_identity_binding
WHERE user_id = ? + + Backend->>Backend: 验证目标租户是否在允许列表中 + alt 目标租户不在允许列表 + Backend-->>Frontend: 返回 403 Forbidden
{message: "无权访问该租户"} + Frontend->>User: 显示错误提示 + end + + Backend->>SSO: POST /auth/sso/refresh-token
{
targetTenantNo: "6000",
refreshToken: "RT-xxx"
} + + SSO->>SSO: 验证 Refresh Token + alt Refresh Token 无效或过期 + SSO-->>Backend: 返回 401/415 (缺少或无效 Token) + Backend-->>Frontend: 返回 401 + Frontend->>User: 提示"会话已过期,请重新登录" + Frontend->>Frontend: 清除本地认证状态 + Frontend->>Frontend: 跳转到登录页 + end + + SSO->>SSO: 验证用户对目标租户的访问权限 + alt 用户无权访问目标租户 + SSO-->>Backend: 返回 403 + Backend-->>Frontend: 返回 403 + Frontend->>User: 提示"无权访问该租户" + end + + SSO->>SSO: 生成新的 Token(包含目标租户信息) + SSO-->>Backend: 返回新的 Token 和用户信息
{
accessToken: "AT-new-xxx",
refreshToken: "RT-new-xxx",
expiresIn: 1800,
user: {
tenantNo: "6000",
tenantName: "江苏东方盛虹",
...
}
} + + Backend->>Backend: 解析新 Token 中的用户信息 + Backend->>DB: 更新身份绑定记录的当前租户
UPDATE enterprise_identity_binding
SET tenant_no = "6000", updated_at = NOW()
WHERE user_id = ? + + Backend->>Redis: 清除旧租户的 Token 缓存
DEL sso:access_token:{userId}:8000 + Backend->>Redis: 缓存新租户的 Access Token
SET sso:access_token:{userId}:6000 {token}
EXPIRE 1800 + + Backend->>Redis: 更新 Refresh Token 缓存
SET sso:refresh_token:{userId} {newRefreshToken}
EXPIRE 7200 + + Backend->>Redis: 更新用户会话
HSET session:{sessionId}
tenantNo "6000"
accessToken "AT-new-xxx"
refreshToken "RT-new-xxx" + + Backend->>SSO: GET /userinfo
Header: Authorization: Bearer {newAccessToken} + SSO-->>Backend: 返回目标租户下的完整用户信息
(包含该租户的部门任职信息) + + Backend->>DB: 更新用户在该租户的信息
UPDATE users SET
position_code = ?,
position_name = ?,
updated_at = NOW() + + Backend->>Backend: 生成新的 SkillHub JWT Token + Backend-->>Frontend: 返回切换成功
{
success: true,
data: {
accessToken: "AT-new-xxx",
refreshToken: "RT-new-xxx",
user: {...},
message: "已切换到江苏东方盛虹"
}
} + + Frontend->>Frontend: 更新内存中的 Token + Frontend->>Frontend: 更新全局用户状态
(包括租户信息、部门任职等) + Frontend->>Frontend: 清除所有 API 查询缓存
(React Query: queryClient.clear()) + + Frontend->>User: 显示成功提示
"已切换到江苏东方盛虹" + Frontend->>Frontend: 刷新当前页面
window.location.reload() + + User->>Frontend: 页面重新加载 + Frontend->>Backend: 重新请求数据
(使用新租户的 Token) + + Backend->>DB: 查询该租户的数据
WHERE tenant_no = "6000" + Backend-->>Frontend: 返回新租户的数据 + + Frontend->>User: 展示新租户的页面内容 + + Note over User,Frontend: 租户切换完成
当前租户: 江苏东方盛虹(6000) +``` + +## 租户切换机制详解 + +### 1. 租户列表来源 + +用户的租户列表在登录时从 SSO 获取: + +```json +{ + "tenantList": [ + { + "employeeId": 74, + "empName": "张波", + "tenantNo": "8000", + "tenantName": "盛虹石化", + "position": "综合副经理" + }, + { + "employeeId": 66, + "empName": "用户-QGJNDWWD", + "tenantNo": "6000", + "tenantName": "江苏东方盛虹股份有限公司", + "position": "部门经理" + } + ] +} +``` + +**前端展示逻辑**: + +```typescript +// 租户下拉菜单组件 +function TenantSwitcher() { + const { user } = useAuth() + const { switchTenant, isLoading } = useTenantSwitch() + + const currentTenant = user?.tenantNo + const tenantList = user?.tenantList || [] + + return ( + + ) +} +``` + +### 2. Token 刷新策略 + +切换租户时,必须刷新 Token 的原因: + +1. **租户隔离**: Access Token 中包含租户信息,用于后端数据隔离 +2. **权限变更**: 不同租户下用户可能有不同的权限 +3. **审计追踪**: 新 Token 记录租户切换操作 + +**Token 中的租户信息**: + +```json +{ + "userId": 123, + "username": "zhangbo", + "tenantNo": "6000", // 当前租户 + "employeeId": 66, // 该租户下的员工 ID + "iat": 1680000000, + "exp": 1680001800 +} +``` + +### 3. 数据隔离机制 + +#### 后端数据查询 + +所有数据查询都带租户过滤: + +```java +@RestController +public class DataController { + + @GetMapping("/api/projects") + public List getProjects(@AuthenticationPrincipal PlatformPrincipal principal) { + String tenantNo = principal.getTenantNo(); + // 只查询当前租户的数据 + return projectRepository.findByTenantNo(tenantNo); + } +} +``` + +#### 数据库设计 + +所有业务表都包含租户字段: + +```sql +CREATE TABLE projects ( + id BIGINT PRIMARY KEY, + tenant_no VARCHAR(32) NOT NULL, -- 租户号 + name VARCHAR(255), + -- 其他字段... + INDEX idx_tenant (tenant_no) +); +``` + +### 4. 缓存清除策略 + +切换租户后需要清除的缓存: + +```typescript +// 前端缓存清除 +async function switchTenant(tenantNo: string) { + // 1. 调用切换接口 + const result = await api.switchTenant(tenantNo) + + // 2. 清除 React Query 缓存 + queryClient.clear() + + // 3. 清除本地存储(如果有) + localStorage.removeItem('cached-data') + + // 4. 重置全局状态 + resetGlobalState() + + // 5. 刷新页面 + window.location.reload() +} +``` + +```java +// 后端缓存清除 +public void switchTenant(String userId, String newTenantNo) { + // 清除旧租户的数据缓存 + redisTemplate.delete("user:" + userId + ":data:*"); + + // 清除旧租户的权限缓存 + redisTemplate.delete("user:" + userId + ":permissions"); + + // 更新会话中的租户信息 + updateSessionTenant(userId, newTenantNo); +} +``` + +## 租户切换的前端实现 + +### useTenantSwitch Hook + +```typescript +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { authApi } from '@/api/client' +import type { User } from '@/api/types' + +interface TenantSwitchOptions { + onSuccess?: (user: User) => void + onError?: (error: Error) => void +} + +export function useTenantSwitch(options?: TenantSwitchOptions) { + const queryClient = useQueryClient() + + const mutation = useMutation({ + mutationFn: async (targetTenantNo: string) => { + // 获取当前的 Refresh Token + const refreshToken = getRefreshToken() + + if (!refreshToken) { + throw new Error('Refresh Token 不存在,请重新登录') + } + + // 调用租户切换接口 + return authApi.switchTenant({ + targetTenantNo, + refreshToken + }) + }, + + onSuccess: (data) => { + const { accessToken, refreshToken, user } = data + + // 更新 Token + setAccessToken(accessToken) + setRefreshToken(refreshToken) + + // 更新用户信息 + queryClient.setQueryData(['auth', 'me'], user) + + // 清除所有查询缓存 + queryClient.clear() + + // 回调 + options?.onSuccess?.(user) + + // 刷新页面以确保数据完全更新 + setTimeout(() => { + window.location.reload() + }, 500) + }, + + onError: (error) => { + if (error.message.includes('401')) { + // Token 过期,跳转登录 + redirectToLogin() + } else if (error.message.includes('403')) { + // 无权访问 + showError('您无权访问该租户') + } + + options?.onError?.(error) + } + }) + + return { + switchTenant: mutation.mutate, + isLoading: mutation.isPending, + error: mutation.error + } +} +``` + +### 租户选择器组件 + +```typescript +import { useState } from 'react' +import { Select, Modal, message } from 'antd' +import { useAuth } from '@/features/auth/use-auth' +import { useTenantSwitch } from '@/features/auth/use-tenant-switch' + +export function TenantSwitcher() { + const { user } = useAuth() + const { switchTenant, isLoading } = useTenantSwitch({ + onSuccess: (newUser) => { + message.success(`已切换到 ${newUser.tenantName}`) + }, + onError: (error) => { + message.error(error.message) + } + }) + + const [confirmVisible, setConfirmVisible] = useState(false) + const [targetTenant, setTargetTenant] = useState() + + const handleTenantChange = (tenantNo: string) => { + if (tenantNo === user?.tenantNo) { + return // 当前租户,不切换 + } + + setTargetTenant(tenantNo) + setConfirmVisible(true) + } + + const handleConfirm = () => { + if (targetTenant) { + switchTenant(targetTenant) + setConfirmVisible(false) + } + } + + const currentTenant = user?.tenantNo + const tenantList = user?.tenantList || [] + + if (tenantList.length <= 1) { + // 只有一个租户,不显示切换器 + return null + } + + return ( + <> + + + setConfirmVisible(false)} + okText="确定" + cancelText="取消" + > +

切换租户将刷新页面,未保存的数据可能会丢失。

+

是否继续切换?

+
+ + ) +} +``` + +## 后端实现 + +### SsoController - 租户切换接口 + +```java +@RestController +@RequestMapping("/api/auth/sso") +public class SsoController { + + @Autowired + private EnterpriseSsoClient ssoClient; + + @Autowired + private IdentityBindingService identityBindingService; + + @Autowired + private RedisTemplate redisTemplate; + + @PostMapping("/switch-tenant") + public ResponseEntity switchTenant( + @RequestBody SwitchTenantRequest request, + @AuthenticationPrincipal PlatformPrincipal principal) { + + String userId = principal.getUserId(); + String targetTenantNo = request.getTargetTenantNo(); + String refreshToken = request.getRefreshToken(); + + // 1. 验证用户是否有权访问目标租户 + List allowedTenants = identityBindingService.getAllowedTenants(userId); + if (!allowedTenants.contains(targetTenantNo)) { + throw new AccessDeniedException("无权访问该租户"); + } + + // 2. 调用 SSO 刷新 Token + SsoTokenResponse tokenResponse = ssoClient.refreshToken( + targetTenantNo, + refreshToken + ); + + // 3. 更新身份绑定记录 + identityBindingService.updateCurrentTenant(userId, targetTenantNo); + + // 4. 清除旧租户缓存 + clearTenantCache(userId); + + // 5. 缓存新 Token + cacheTokens(userId, targetTenantNo, tokenResponse); + + // 6. 获取目标租户的用户信息 + User user = ssoClient.getUserInfo(tokenResponse.getAccessToken()); + + // 7. 更新用户信息 + identityBindingService.updateUserInfo(userId, user); + + // 8. 返回结果 + return ResponseEntity.ok(SwitchTenantResponse.builder() + .accessToken(tokenResponse.getAccessToken()) + .refreshToken(tokenResponse.getRefreshToken()) + .expiresIn(tokenResponse.getExpiresIn()) + .user(user) + .build()); + } + + private void clearTenantCache(String userId) { + // 清除数据缓存 + redisTemplate.delete("user:" + userId + ":*"); + + // 清除权限缓存 + redisTemplate.delete("permissions:" + userId); + } + + private void cacheTokens(String userId, String tenantNo, SsoTokenResponse tokens) { + // 缓存 Access Token + String accessTokenKey = String.format("sso:access_token:%s:%s", userId, tenantNo); + redisTemplate.opsForValue().set( + accessTokenKey, + tokens.getAccessToken(), + tokens.getExpiresIn(), + TimeUnit.SECONDS + ); + + // 缓存 Refresh Token + String refreshTokenKey = String.format("sso:refresh_token:%s", userId); + redisTemplate.opsForValue().set( + refreshTokenKey, + tokens.getRefreshToken(), + tokens.getRefreshExpiresIn(), + TimeUnit.SECONDS + ); + } +} +``` + +## 安全考虑 + +### 1. 权限验证 + +严格验证用户对目标租户的访问权限: + +```java +public boolean hasAccessToTenant(String userId, String tenantNo) { + // 查询用户的租户列表 + List tenants = identityBindingRepository + .findTenantsByUserId(userId); + + // 检查目标租户是否在列表中 + return tenants.stream() + .anyMatch(t -> t.getTenantNo().equals(tenantNo)); +} +``` + +### 2. 审计日志 + +记录所有租户切换操作: + +```java +@Aspect +public class TenantSwitchAudit { + + @AfterReturning("execution(* switchTenant(..))") + public void auditTenantSwitch(JoinPoint joinPoint) { + SwitchTenantRequest request = (SwitchTenantRequest) joinPoint.getArgs()[0]; + PlatformPrincipal principal = (PlatformPrincipal) joinPoint.getArgs()[1]; + + auditLog.info("用户 {} 从租户 {} 切换到租户 {}", + principal.getUserId(), + principal.getTenantNo(), + request.getTargetTenantNo() + ); + } +} +``` + +### 3. 频率限制 + +防止恶意频繁切换: + +```java +public void switchTenant(...) { + String rateLimitKey = "tenant:switch:limit:" + userId; + Long count = redisTemplate.opsForValue().increment(rateLimitKey); + + if (count == 1) { + // 首次设置过期时间 + redisTemplate.expire(rateLimitKey, 1, TimeUnit.MINUTES); + } + + if (count > 10) { + throw new RateLimitException("切换过于频繁,请稍后再试"); + } + + // ... 正常切换逻辑 +} +``` + +## 测试用例 + +### 正常流程测试 + +- [x] 切换到有权限的租户成功 +- [x] 数据正确隔离 +- [x] Token 正确刷新 +- [x] 用户信息正确更新 +- [x] 缓存正确清除 + +### 异常流程测试 + +- [x] 切换到无权限租户被拒绝 +- [x] Refresh Token 过期跳转登录 +- [x] 频繁切换触发限流 +- [x] SSO 系统不可用 +- [x] 并发切换处理 + +### 性能测试 + +- [x] 100 用户同时切换租户 +- [x] 切换后首页加载速度 +- [x] 缓存清除性能影响 + +--- + +**相关文档**: +- [企业 SSO 登录接入方案设计](./企业SSO登录接入方案设计.md) +- [企业 SSO 首次登录流程](./企业SSO首次登录流程.md) +- [企业 SSO 常规登录流程](./企业SSO常规登录流程.md) diff --git a/docs/sh-sso/企业SSO系统架构图.md b/docs/sh-sso/企业SSO系统架构图.md new file mode 100644 index 00000000..0a570a09 --- /dev/null +++ b/docs/sh-sso/企业SSO系统架构图.md @@ -0,0 +1,592 @@ +# 企业 SSO 系统架构图 + +## 总体架构 + +```mermaid +graph TB + subgraph "用户层" + U1[用户浏览器] + U2[移动端 App] + end + + subgraph "前端层" + FE1[React 应用] + FE2[登录页面] + FE3[SSO 回调页面] + FE4[租户切换组件] + end + + subgraph "网关层" + GW[API Gateway / Nginx] + end + + subgraph "后端服务层" + subgraph "认证服务" + AS1[SsoController] + AS2[EnterpriseDirectAuthProvider] + AS3[SecurityFilterChain] + end + + subgraph "业务服务" + BS1[User Service] + BS2[Project Service] + BS3[其他业务服务] + end + + subgraph "核心服务" + CS1[IdentityBindingService] + CS2[TokenService] + CS3[TenantService] + end + end + + subgraph "数据层" + DB[(MySQL Database)] + Redis[(Redis Cache)] + end + + subgraph "外部系统" + SSO[企业 SSO 认证系统] + end + + U1 --> FE1 + U2 --> FE1 + FE1 --> FE2 + FE1 --> FE3 + FE1 --> FE4 + + FE1 --> GW + FE2 --> GW + FE3 --> GW + FE4 --> GW + + GW --> AS1 + GW --> BS1 + GW --> BS2 + GW --> BS3 + + AS1 --> AS2 + AS1 --> AS3 + AS2 --> CS1 + AS2 --> CS2 + AS1 --> CS3 + + BS1 --> CS3 + BS2 --> CS3 + + CS1 --> DB + CS2 --> Redis + CS3 --> DB + CS3 --> Redis + + AS2 --> SSO + AS1 --> SSO +``` + +## 认证流程架构 + +```mermaid +flowchart LR + subgraph "请求入口" + A[HTTP Request] + end + + subgraph "Spring Security Filter Chain" + B[ApiTokenAuthFilter] + C[SessionAuthFilter] + D[SsoAuthFilter] + E[AnonymousAuthFilter] + end + + subgraph "认证提供者" + F[ApiTokenAuthProvider] + G[SessionAuthProvider] + H[EnterpriseDirectAuthProvider] + end + + subgraph "认证结果" + I[PlatformPrincipal] + J[Authentication Success] + K[Authentication Failure] + end + + A --> B + B -->|有 API Token| F + B -->|无 API Token| C + + C -->|有 Session| G + C -->|无 Session| D + + D -->|SSO Token| H + D -->|无 Token| E + + F --> I + G --> I + H --> I + + I --> J + E --> K +``` + +## Token 生命周期管理 + +```mermaid +stateDiagram-v2 + [*] --> 未登录 + + 未登录 --> SSO认证: 用户点击登录 + + SSO认证 --> Token签发: SSO 验证成功 + Token签发 --> Token有效: 签发 Access + Refresh Token + + Token有效 --> Token即将过期: 使用 < 5分钟 + Token即将过期 --> 自动刷新: 后台刷新 + 自动刷新 --> Token有效: 刷新成功 + + Token有效 --> Token过期: 超过有效期 + Token过期 --> 尝试刷新: 前端拦截 401 + 尝试刷新 --> Token有效: Refresh Token 有效 + 尝试刷新 --> 未登录: Refresh Token 过期 + + Token有效 --> 租户切换: 用户切换租户 + 租户切换 --> Token签发: 刷新为新租户 Token + + Token有效 --> 未登录: 用户主动登出 +``` + +## 数据库设计 + +```mermaid +erDiagram + users ||--o{ enterprise_identity_binding : has + users ||--o{ user_role_binding : has + roles ||--o{ user_role_binding : has + roles ||--o{ role_permission : has + permissions ||--o{ role_permission : has + users ||--o{ api_tokens : creates + + users { + bigint id PK + string username UK + string display_name + string email + string phone + string status + timestamp created_at + timestamp updated_at + } + + enterprise_identity_binding { + bigint id PK + bigint user_id FK + bigint enterprise_user_id + bigint employee_id + string tenant_no + string provider + timestamp created_at + timestamp updated_at + } + + user_role_binding { + bigint id PK + bigint user_id FK + bigint role_id FK + string tenant_no + timestamp created_at + } + + roles { + bigint id PK + string code UK + string name + string description + timestamp created_at + } + + role_permission { + bigint id PK + bigint role_id FK + bigint permission_id FK + } + + permissions { + bigint id PK + string code UK + string name + string resource + string action + timestamp created_at + } + + api_tokens { + bigint id PK + bigint user_id FK + string token_hash + string scope + timestamp expires_at + timestamp created_at + } +``` + +## Redis 缓存架构 + +```mermaid +graph TB + subgraph "Token 缓存" + T1["Key: sso:access_token:{userId}:{tenantNo}
Value: Access Token
TTL: 30分钟"] + T2["Key: sso:refresh_token:{userId}
Value: Refresh Token
TTL: 2小时"] + end + + subgraph "会话缓存" + S1["Key: session:{sessionId}
Value: Session Data
TTL: 2小时"] + end + + subgraph "用户信息缓存" + U1["Key: user:info:{userId}
Value: User Profile
TTL: 1小时"] + U2["Key: user:tenants:{userId}
Value: Tenant List
TTL: 1小时"] + end + + subgraph "权限缓存" + P1["Key: permissions:{userId}:{tenantNo}
Value: Permission List
TTL: 30分钟"] + end + + subgraph "限流缓存" + R1["Key: rate:limit:login:{ip}
Value: Counter
TTL: 5分钟"] + R2["Key: rate:limit:switch:{userId}
Value: Counter
TTL: 1分钟"] + end + + subgraph "防重放缓存" + A1["Key: auth:state:{state}
Value: Nonce
TTL: 5分钟"] + end +``` + +## 多租户数据隔离 + +```mermaid +flowchart TB + subgraph "请求处理" + A[API Request] + B[Extract Token] + C[Parse Tenant Info] + end + + subgraph "租户上下文" + D[TenantContext] + E[ThreadLocal] + end + + subgraph "数据访问层" + F[MyBatis Interceptor] + G[Auto Inject tenant_no] + H[SQL: WHERE tenant_no = ?] + end + + subgraph "数据库" + I[(Multi-Tenant Data)] + end + + A --> B + B --> C + C --> D + D --> E + + E --> F + F --> G + G --> H + H --> I + + style D fill:#f9f,stroke:#333 + style E fill:#f9f,stroke:#333 +``` + +## 安全防护层次 + +```mermaid +graph TB + subgraph "网络层" + N1[HTTPS/TLS 1.3] + N2[DDoS 防护] + N3[WAF 防火墙] + end + + subgraph "应用层" + A1[CSRF Token] + A2[XSS 过滤] + A3[SQL 注入防护] + A4[参数验证] + end + + subgraph "认证层" + AU1[Token 加密] + AU2[Token 签名验证] + AU3[State 参数验证] + AU4[Refresh Token 轮转] + end + + subgraph "授权层" + AZ1[RBAC 权限控制] + AZ2[租户隔离] + AZ3[API 权限验证] + end + + subgraph "审计层" + L1[访问日志] + L2[操作审计] + L3[异常告警] + end + + N1 --> A1 + N2 --> A2 + N3 --> A3 + A4 --> AU1 + AU2 --> AZ1 + AU3 --> AZ2 + AU4 --> AZ3 + AZ1 --> L1 + AZ2 --> L2 + AZ3 --> L3 +``` + +## 部署架构 + +```mermaid +graph TB + subgraph "负载均衡层" + LB[Nginx / ALB] + end + + subgraph "应用服务器集群" + APP1[SkillHub Server 1] + APP2[SkillHub Server 2] + APP3[SkillHub Server 3] + end + + subgraph "数据库集群" + subgraph "主从复制" + DBM[(MySQL Master)] + DBS1[(MySQL Slave 1)] + DBS2[(MySQL Slave 2)] + end + end + + subgraph "缓存集群" + subgraph "Redis Sentinel" + RM[(Redis Master)] + RS1[(Redis Slave 1)] + RS2[(Redis Slave 2)] + end + end + + subgraph "外部依赖" + SSO[企业 SSO 系统] + end + + subgraph "监控系统" + M1[Prometheus] + M2[Grafana] + M3[AlertManager] + end + + LB --> APP1 + LB --> APP2 + LB --> APP3 + + APP1 --> DBM + APP2 --> DBM + APP3 --> DBM + + DBM --> DBS1 + DBM --> DBS2 + + APP1 --> RM + APP2 --> RM + APP3 --> RM + + RM --> RS1 + RM --> RS2 + + APP1 --> SSO + APP2 --> SSO + APP3 --> SSO + + APP1 --> M1 + APP2 --> M1 + APP3 --> M1 + + M1 --> M2 + M1 --> M3 +``` + +## 关键组件说明 + +### 1. EnterpriseDirectAuthProvider + +**职责**: +- 实现 `DirectAuthProvider` 接口 +- 对接企业 SSO 认证 API +- Token 获取与验证 +- 用户信息映射 + +**核心方法**: + +```java +public interface DirectAuthProvider { + String providerCode(); + PlatformPrincipal authenticate(DirectAuthRequest request); +} + +@Service +public class EnterpriseDirectAuthProvider implements DirectAuthProvider { + + @Override + public String providerCode() { + return "enterprise-sso"; + } + + @Override + public PlatformPrincipal authenticate(DirectAuthRequest request) { + // 1. 调用 SSO 登录 API + // 2. 获取用户信息 + // 3. 绑定或创建平台账号 + // 4. 返回 PlatformPrincipal + } +} +``` + +### 2. IdentityBindingService + +**职责**: +- 企业账号与平台账号的绑定关系管理 +- 用户信息同步 +- 租户信息管理 + +**核心方法**: + +```java +@Service +public class IdentityBindingService { + + // 绑定或创建账号 + PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus status); + + // 查询允许访问的租户列表 + List getAllowedTenants(String userId); + + // 更新当前租户 + void updateCurrentTenant(String userId, String tenantNo); + + // 同步用户信息 + void syncUserInfo(String userId, EnterpriseSsoUser ssoUser); +} +``` + +### 3. TokenService + +**职责**: +- Token 缓存管理 +- Token 刷新逻辑 +- Token 验证 + +**核心方法**: + +```java +@Service +public class TokenService { + + // 缓存 Token + void cacheToken(String userId, String tenantNo, TokenPair tokens); + + // 获取 Token + Optional getAccessToken(String userId, String tenantNo); + + // 刷新 Token + TokenPair refreshToken(String userId, String refreshToken); + + // 验证 Token + boolean validateToken(String token); +} +``` + +### 4. TenantService + +**职责**: +- 租户上下文管理 +- 租户数据隔离 +- 租户权限验证 + +**核心方法**: + +```java +@Service +public class TenantService { + + // 设置当前租户上下文 + void setCurrentTenant(String tenantNo); + + // 获取当前租户 + String getCurrentTenant(); + + // 验证租户访问权限 + boolean hasAccessToTenant(String userId, String tenantNo); + + // 清除租户上下文 + void clearTenantContext(); +} +``` + +## 性能优化策略 + +### 1. 缓存策略 + +- **多级缓存**: 本地缓存 (Caffeine) + Redis +- **缓存预热**: 启动时预加载热点数据 +- **缓存更新**: 使用 Redis Pub/Sub 同步多实例 + +### 2. 数据库优化 + +- **读写分离**: 查询走从库,写入走主库 +- **连接池**: 使用 HikariCP,合理配置连接数 +- **索引优化**: tenant_no、user_id 等常用字段建索引 + +### 3. 异步处理 + +- **用户信息同步**: 异步更新用户信息 +- **审计日志**: 异步写入日志 +- **通知发送**: 使用消息队列异步发送 + +### 4. 限流熔断 + +- **接口限流**: 使用 Guava RateLimiter 或 Sentinel +- **熔断降级**: 企业 SSO 不可用时启用降级方案 +- **超时控制**: 设置合理的 HTTP 超时时间 + +## 监控指标 + +### 1. 业务指标 + +- 登录成功率 +- 登录耗时 +- Token 刷新成功率 +- 租户切换成功率 + +### 2. 技术指标 + +- API 响应时间 +- 数据库连接数 +- Redis 命中率 +- JVM 内存使用 + +### 3. 安全指标 + +- 登录失败次数 +- 异常 IP 访问 +- Token 泄露检测 +- 权限越权尝试 + +--- + +**相关文档**: +- [企业 SSO 登录接入方案设计](./企业SSO登录接入方案设计.md) +- [企业 SSO 首次登录流程](./企业SSO首次登录流程.md) +- [企业 SSO 常规登录流程](./企业SSO常规登录流程.md) +- [企业 SSO 租户切换流程](./企业SSO租户切换流程.md) diff --git a/docs/sh-sso/企业SSO首次登录流程.md b/docs/sh-sso/企业SSO首次登录流程.md new file mode 100644 index 00000000..0820df13 --- /dev/null +++ b/docs/sh-sso/企业SSO首次登录流程.md @@ -0,0 +1,226 @@ +# 企业 SSO 首次登录流程 + +## 流程图 + +```mermaid +sequenceDiagram + autonumber + participant User as 用户浏览器 + participant Frontend as SkillHub 前端 + participant Backend as SkillHub 后端 + participant SSO as 企业 SSO 系统 + participant Redis as Redis 缓存 + participant DB as 数据库 + + User->>Frontend: 访问登录页 + Frontend->>User: 展示登录页面(显示"企业账号登录"按钮) + + User->>Frontend: 点击"企业账号登录" + Frontend->>Backend: GET /api/auth/sso/login-url?redirectUri={callback} + + Backend->>Backend: 生成 state 参数(防 CSRF) + Backend->>Redis: 缓存 state (TTL: 5分钟) + Backend->>Backend: 构建 SSO 登录 URL + Backend-->>Frontend: 返回 SSO 登录地址 + + Frontend->>User: 重定向到企业 SSO 登录页 + Note over User,SSO: 跳转到企业 SSO 认证系统 + + User->>SSO: 访问 SSO 登录页 + SSO->>User: 展示登录表单 + + User->>SSO: 输入企业账号和密码 + SSO->>SSO: 验证用户凭证 + + alt 认证失败 + SSO->>User: 显示错误信息 + User->>SSO: 重新输入 + end + + SSO->>SSO: 认证成功,生成授权码 (code) + SSO->>User: 重定向回 SkillHub callback (携带 code 和 state) + + User->>Frontend: 访问 /auth/sso/callback?code=xxx&state=yyy + Frontend->>Backend: POST /api/auth/sso/callback
{code, state} + + Backend->>Redis: 验证 state 参数 + alt state 无效或过期 + Backend-->>Frontend: 返回错误(CSRF 攻击) + Frontend->>User: 显示错误,重新登录 + end + + Backend->>SSO: POST /auth/sso/login
使用 code 换取 Token + SSO->>SSO: 验证授权码 + SSO-->>Backend: 返回 Access Token + Refresh Token + 用户信息 + + Backend->>Backend: 解析用户信息 + Backend->>SSO: GET /userinfo
获取完整用户信息 + SSO-->>Backend: 返回用户详细信息(包含租户列表、部门任职等) + + Backend->>Backend: 判断 firstLogin 标记 + + alt firstLogin = true (首次登录) + Backend->>Redis: 缓存 Token 和用户信息(临时) + Backend-->>Frontend: 返回 {requirePasswordChange: true, tempToken} + + Frontend->>User: 显示"首次登录,请修改密码"提示 + User->>Frontend: 输入新密码 + Frontend->>Backend: POST /api/auth/sso/change-password
{tempToken, newPassword, logoutAfterChange: false} + + Backend->>SSO: POST /password/save-password
修改密码 + SSO->>SSO: 验证密码强度 + + alt 密码不符合要求 + SSO-->>Backend: 返回错误(密码必须包含大小写字母和数字,长度8-20位) + Backend-->>Frontend: 返回错误 + Frontend->>User: 显示密码要求,重新输入 + end + + SSO-->>Backend: 密码修改成功 + Backend->>DB: 创建平台账号 + Backend->>DB: 创建身份绑定记录
(EnterpriseIdentityBinding) + Backend->>DB: 分配默认角色 + Backend->>Redis: 缓存正式 Token 和会话 + Backend-->>Frontend: 返回登录成功 {user, token} + else firstLogin = false + Note over Backend: 这种情况不应该出现在首次登录流程 + Backend-->>Frontend: 返回错误 + end + + Frontend->>Frontend: 保存 Token (内存) + Frontend->>Frontend: 更新全局认证状态 + Frontend->>User: 跳转到首页 + + User->>Frontend: 访问首页 + Frontend->>Backend: GET /api/user/profile
Header: Authorization: Bearer {token} + Backend->>Redis: 验证 Token + Backend->>DB: 查询用户信息 + Backend-->>Frontend: 返回用户信息 + Frontend->>User: 展示首页内容 +``` + +## 关键步骤说明 + +### 1. 前端跳转 SSO + +- 前端调用后端接口获取 SSO 登录 URL +- 后端生成唯一的 `state` 参数用于防 CSRF 攻击 +- 前端执行 `window.location.href = ssoLoginUrl` 跳转 + +### 2. SSO 认证 + +- 用户在企业 SSO 系统输入账号密码 +- SSO 验证通过后生成授权码(code) +- SSO 重定向回 SkillHub 回调地址 + +### 3. 授权码换 Token + +- SkillHub 后端接收授权码 +- 验证 state 参数防止 CSRF +- 使用授权码向 SSO 换取 Access Token 和 Refresh Token + +### 4. 首次登录处理 + +**判断依据**: `user.firstLogin === true` + +**处理流程**: +1. 后端返回 `requirePasswordChange: true` +2. 前端显示修改密码表单 +3. 用户输入新密码 +4. 调用密码修改接口 +5. SSO 验证密码强度(必须包含大小写字母和数字,8-20位) +6. 修改成功后创建平台账号并绑定 +7. 返回正式 Token,完成登录 + +### 5. 账号绑定 + +创建以下绑定关系: + +```sql +INSERT INTO enterprise_identity_binding ( + user_id, + enterprise_user_id, + employee_id, + tenant_no, + provider, + created_at +) VALUES ( + 123, -- SkillHub 平台用户 ID + 74, -- 企业 SSO 用户 ID + 74, -- 员工 ID + '8000', -- 当前租户号 + 'enterprise-sso', -- 认证提供方 + NOW() +); +``` + +### 6. Token 缓存 + +Redis 缓存结构: + +``` +# 用户会话 +Key: session:{userId} +Value: { + "accessToken": "AT-xxx", + "refreshToken": "RT-xxx", + "tenantNo": "8000", + "expiresAt": 1680001800 +} +TTL: 1800 (30分钟) +``` + +## 错误处理 + +### 常见错误场景 + +| 错误码 | 场景 | 用户提示 | 处理方式 | +|--------|------|----------|----------| +| 401 | 用户不存在或密码不正确 | "账号或密码错误,请重试" | 返回登录页 | +| 403 | state 参数无效 (CSRF) | "登录链接已失效,请重新登录" | 重新获取登录 URL | +| 406 | 密码不符合强度要求 | "密码必须包含大小写字母和数字,长度8-20位" | 提示重新输入 | +| 415 | Token 缺失或无效 | "登录已过期,请重新登录" | 跳转登录页 | +| 500 | SSO 系统异常 | "系统繁忙,请稍后重试" | 提供降级方案 | + +## 安全要点 + +1. **HTTPS Only**: 所有请求必须使用 HTTPS +2. **State 验证**: 严格验证 state 参数防止 CSRF +3. **Token 安全**: + - Access Token 存储在内存(不用 LocalStorage) + - Refresh Token 存储在 HttpOnly Cookie +4. **密码强度**: 强制8-20位,必须包含大小写字母和数字 +5. **Session 过期**: Access Token 30分钟过期,自动刷新 +6. **日志审计**: 记录所有登录行为 + +## 性能优化 + +1. **Redis 缓存**: 缓存 Token 和用户信息,减少数据库查询 +2. **并发处理**: 使用分布式锁防止重复创建账号 +3. **异步处理**: 用户信息同步采用异步方式 +4. **连接池**: 配置合理的 Redis 和数据库连接池 + +## 测试用例 + +### 正常流程测试 + +- [x] 新用户首次登录成功 +- [x] 修改密码成功 +- [x] 账号绑定成功 +- [x] Token 缓存成功 +- [x] 跳转首页成功 + +### 异常流程测试 + +- [x] SSO 登录失败(账号密码错误) +- [x] State 参数被篡改(CSRF 攻击) +- [x] 密码强度不符合要求 +- [x] SSO 系统不可用(超时) +- [x] 重复创建账号(并发) + +--- + +**相关文档**: +- [企业 SSO 登录接入方案设计](./企业SSO登录接入方案设计.md) +- [企业 SSO 常规登录流程](./企业SSO常规登录流程.md) +- [企业 SSO 租户切换流程](./企业SSO租户切换流程.md)