feat : 新增盛虹sso登陆设计文档

This commit is contained in:
翟二远 2026-04-03 16:15:39 +08:00
parent fa981f455c
commit 86c0266cc4
7 changed files with 3296 additions and 0 deletions

313
docs/sh-sso/README.md Normal file
View file

@ -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 索引
所有文档已完成!✅

View file

@ -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/...`。

View file

@ -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<br/>{code, state}
Backend->>Redis: 验证 state 参数
alt state 无效
Backend-->>Frontend: 返回 CSRF 错误
Frontend->>User: 提示重新登录
end
Backend->>SSO: POST /auth/sso/login<br/>使用 code 换取 Token
SSO-->>Backend: 返回 Access Token + Refresh Token
Backend->>Backend: 解析 Token 中的用户 ID
Backend->>DB: 查询身份绑定记录<br/>WHERE enterprise_user_id = ?
alt 绑定记录不存在
Note over Backend: 用户从未登录过,应走首次登录流程
Backend-->>Frontend: 返回错误(未绑定)
Frontend->>User: 提示联系管理员
end
Backend->>SSO: GET /userinfo<br/>Header: Authorization: Bearer {token}
SSO-->>Backend: 返回最新用户信息
Backend->>DB: 更新用户信息<br/>UPDATE users SET<br/>nick_name=?, phone=?, updated_at=NOW()
Backend->>DB: 更新身份绑定信息<br/>UPDATE enterprise_identity_binding<br/>SET tenant_no=?, updated_at=NOW()
Backend->>Redis: 缓存 Access Token<br/>Key: sso:access_token:{userId}<br/>TTL: 1800 (30分钟)
Backend->>Redis: 缓存 Refresh Token<br/>Key: sso:refresh_token:{userId}<br/>TTL: 7200 (2小时)
Backend->>Redis: 缓存用户会话<br/>Key: session:{sessionId}<br/>TTL: 7200
Backend->>Backend: 生成 SkillHub JWT Token
Backend-->>Frontend: 返回登录成功<br/>{user, token, requirePasswordChange: false}
Frontend->>Frontend: 保存 Token 到内存
Frontend->>Frontend: 更新全局认证状态
alt 有 returnTo 参数
Frontend->>User: 跳转到指定页面
else 无 returnTo 参数
Frontend->>User: 跳转到首页
end
User->>Frontend: 访问目标页面
Frontend->>Backend: API 请求<br/>Header: Authorization: Bearer {token}
Backend->>Redis: 验证 Token 有效性
alt Token 即将过期(剩余时间 < 5分钟)
Backend->>Backend: 触发自动刷新机制
Backend->>SSO: POST /auth/sso/refresh-token<br/>{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<br/>{refreshToken}
SSO->>SSO: 验证 Refresh Token
alt Refresh Token 有效
SSO-->>Backend: 返回新 Access Token
Backend->>Redis: 更新缓存<br/>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)

View file

@ -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<void>
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 | 初始版本 |
---
**文档结束**

View file

@ -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 示例:<br/>1. 盛虹石化 (8000) - 当前<br/>2. 江苏东方盛虹 (6000)
User->>Frontend: 选择目标租户 "江苏东方盛虹 (6000)"
Frontend->>Frontend: 弹出确认对话框<br/>"切换租户将刷新页面,是否继续?"
User->>Frontend: 点击"确定"
Frontend->>Frontend: 从 Cookie/内存中获取 Refresh Token
Frontend->>Backend: POST /api/auth/sso/switch-tenant<br/>{<br/> targetTenantNo: "6000",<br/> refreshToken: "RT-xxx"<br/>}<br/>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: 查询用户的租户列表<br/>SELECT * FROM enterprise_identity_binding<br/>WHERE user_id = ?
Backend->>Backend: 验证目标租户是否在允许列表中
alt 目标租户不在允许列表
Backend-->>Frontend: 返回 403 Forbidden<br/>{message: "无权访问该租户"}
Frontend->>User: 显示错误提示
end
Backend->>SSO: POST /auth/sso/refresh-token<br/>{<br/> targetTenantNo: "6000",<br/> refreshToken: "RT-xxx"<br/>}
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 和用户信息<br/>{<br/> accessToken: "AT-new-xxx",<br/> refreshToken: "RT-new-xxx",<br/> expiresIn: 1800,<br/> user: {<br/> tenantNo: "6000",<br/> tenantName: "江苏东方盛虹",<br/> ...<br/> }<br/>}
Backend->>Backend: 解析新 Token 中的用户信息
Backend->>DB: 更新身份绑定记录的当前租户<br/>UPDATE enterprise_identity_binding<br/>SET tenant_no = "6000", updated_at = NOW()<br/>WHERE user_id = ?
Backend->>Redis: 清除旧租户的 Token 缓存<br/>DEL sso:access_token:{userId}:8000
Backend->>Redis: 缓存新租户的 Access Token<br/>SET sso:access_token:{userId}:6000 {token}<br/>EXPIRE 1800
Backend->>Redis: 更新 Refresh Token 缓存<br/>SET sso:refresh_token:{userId} {newRefreshToken}<br/>EXPIRE 7200
Backend->>Redis: 更新用户会话<br/>HSET session:{sessionId}<br/> tenantNo "6000"<br/> accessToken "AT-new-xxx"<br/> refreshToken "RT-new-xxx"
Backend->>SSO: GET /userinfo<br/>Header: Authorization: Bearer {newAccessToken}
SSO-->>Backend: 返回目标租户下的完整用户信息<br/>(包含该租户的部门任职信息)
Backend->>DB: 更新用户在该租户的信息<br/>UPDATE users SET<br/> position_code = ?,<br/> position_name = ?,<br/> updated_at = NOW()
Backend->>Backend: 生成新的 SkillHub JWT Token
Backend-->>Frontend: 返回切换成功<br/>{<br/> success: true,<br/> data: {<br/> accessToken: "AT-new-xxx",<br/> refreshToken: "RT-new-xxx",<br/> user: {...},<br/> message: "已切换到江苏东方盛虹"<br/> }<br/>}
Frontend->>Frontend: 更新内存中的 Token
Frontend->>Frontend: 更新全局用户状态<br/>(包括租户信息、部门任职等)
Frontend->>Frontend: 清除所有 API 查询缓存<br/>(React Query: queryClient.clear())
Frontend->>User: 显示成功提示<br/>"已切换到江苏东方盛虹"
Frontend->>Frontend: 刷新当前页面<br/>window.location.reload()
User->>Frontend: 页面重新加载
Frontend->>Backend: 重新请求数据<br/>(使用新租户的 Token)
Backend->>DB: 查询该租户的数据<br/>WHERE tenant_no = "6000"
Backend-->>Frontend: 返回新租户的数据
Frontend->>User: 展示新租户的页面内容
Note over User,Frontend: 租户切换完成<br/>当前租户: 江苏东方盛虹(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 (
<Select
value={currentTenant}
onChange={(tenantNo) => switchTenant(tenantNo)}
disabled={isLoading}
>
{tenantList.map(tenant => (
<Option key={tenant.tenantNo} value={tenant.tenantNo}>
{tenant.tenantName}
{tenant.tenantNo === currentTenant && ' (当前)'}
</Option>
))}
</Select>
)
}
```
### 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<Project> 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<User>(['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<string>()
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 (
<>
<Select
value={currentTenant}
onChange={handleTenantChange}
loading={isLoading}
style={{ width: 200 }}
placeholder="选择租户"
>
{tenantList.map(tenant => (
<Select.Option key={tenant.tenantNo} value={tenant.tenantNo}>
{tenant.tenantName}
{tenant.tenantNo === currentTenant && ' ✓'}
</Select.Option>
))}
</Select>
<Modal
title="确认切换租户"
open={confirmVisible}
onOk={handleConfirm}
onCancel={() => setConfirmVisible(false)}
okText="确定"
cancelText="取消"
>
<p>切换租户将刷新页面,未保存的数据可能会丢失。</p>
<p>是否继续切换?</p>
</Modal>
</>
)
}
```
## 后端实现
### SsoController - 租户切换接口
```java
@RestController
@RequestMapping("/api/auth/sso")
public class SsoController {
@Autowired
private EnterpriseSsoClient ssoClient;
@Autowired
private IdentityBindingService identityBindingService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@PostMapping("/switch-tenant")
public ResponseEntity<SwitchTenantResponse> switchTenant(
@RequestBody SwitchTenantRequest request,
@AuthenticationPrincipal PlatformPrincipal principal) {
String userId = principal.getUserId();
String targetTenantNo = request.getTargetTenantNo();
String refreshToken = request.getRefreshToken();
// 1. 验证用户是否有权访问目标租户
List<String> 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<TenantInfo> 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)

View file

@ -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}<br/>Value: Access Token<br/>TTL: 30分钟"]
T2["Key: sso:refresh_token:{userId}<br/>Value: Refresh Token<br/>TTL: 2小时"]
end
subgraph "会话缓存"
S1["Key: session:{sessionId}<br/>Value: Session Data<br/>TTL: 2小时"]
end
subgraph "用户信息缓存"
U1["Key: user:info:{userId}<br/>Value: User Profile<br/>TTL: 1小时"]
U2["Key: user:tenants:{userId}<br/>Value: Tenant List<br/>TTL: 1小时"]
end
subgraph "权限缓存"
P1["Key: permissions:{userId}:{tenantNo}<br/>Value: Permission List<br/>TTL: 30分钟"]
end
subgraph "限流缓存"
R1["Key: rate:limit:login:{ip}<br/>Value: Counter<br/>TTL: 5分钟"]
R2["Key: rate:limit:switch:{userId}<br/>Value: Counter<br/>TTL: 1分钟"]
end
subgraph "防重放缓存"
A1["Key: auth:state:{state}<br/>Value: Nonce<br/>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<String> 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<String> 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)

View file

@ -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<br/>{code, state}
Backend->>Redis: 验证 state 参数
alt state 无效或过期
Backend-->>Frontend: 返回错误(CSRF 攻击)
Frontend->>User: 显示错误,重新登录
end
Backend->>SSO: POST /auth/sso/login<br/>使用 code 换取 Token
SSO->>SSO: 验证授权码
SSO-->>Backend: 返回 Access Token + Refresh Token + 用户信息
Backend->>Backend: 解析用户信息
Backend->>SSO: GET /userinfo<br/>获取完整用户信息
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<br/>{tempToken, newPassword, logoutAfterChange: false}
Backend->>SSO: POST /password/save-password<br/>修改密码
SSO->>SSO: 验证密码强度
alt 密码不符合要求
SSO-->>Backend: 返回错误(密码必须包含大小写字母和数字,长度8-20位)
Backend-->>Frontend: 返回错误
Frontend->>User: 显示密码要求,重新输入
end
SSO-->>Backend: 密码修改成功
Backend->>DB: 创建平台账号
Backend->>DB: 创建身份绑定记录<br/>(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<br/>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)