Files
nanocode/usercenter/docs/sso-integration-guide.md
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

548 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SSO 统一登录接入指南
> 基于 usercenter、personal-blog、www 三个项目的实际落地经验整理。
> 新项目接入 SSO 只需按本文档操作,预计 30 分钟内完成。
---
## 一、架构概览
```
┌─────────────────────────┐
│ SSO 中心 :9000 │
│ (usercenter/app.py) │
│ │
│ 职责: │
│ · 登录页 UI │
│ · 验证账号密码 │
│ · 签发 JWT → 种 Cookie │
│ · 修改密码 / 退出 │
└────────┬────────────────┘
│ Cookie: access_token
│ Domain=129.211.173.87
│ SameSite=Lax; HttpOnly
│
┌──────────────┼──────────────┐
│ │ │
┌─────────▼──────┐ ┌────▼────────┐ ┌──▼───────────┐
│ blog :8888 │ │ www :80 │ │ 新项目 :XXXX │
│ :8889(后端) │ │ :8887(后端) │ │ │
└────────────────┘ └─────────────┘ └──────────────┘
```
**核心思路:同 IP 跨端口共享 Cookie**
- 所有服务跑在同一台机器 `129.211.173.87`,不同端口
- SSO 登录成功后,种下 `Domain=129.211.173.87` 的 Cookie
- 浏览器对同 IP 不同端口的请求会自动携带该 Cookie
- 各业务后端 **本地验签** JWT(共享 SECRET_KEY),无需每请求调 SSO 接口
---
## 二、环境约束与前提
| 项目 | 值 |
|------|-----|
| 服务器 IP | `129.211.173.87` |
| 协议 | HTTP(无 HTTPS) |
| SSO 中心端口 | `9000` |
| Cookie 域 | `129.211.173.87` |
| Cookie SameSite | `Lax`(同 IP 跨端口足够,`None` 需 HTTPS) |
| JWT 算法 | HS256 |
| 默认账号 | `admin / abcd1234` |
**关键约束:**
1. **HTTP 下不能用 `SameSite=None`** —— `None` 要求配 `Secure` 属性,而 `Secure` 要求 HTTPS,我们没 HTTPS
2. **同 IP + `SameSite=Lax` 即可跨端口共享** —— 浏览器把不同端口视为同站点
3. **跨顶级域名(a.com vs b.com)不适用此方案** —— 那种需要 OAuth2/OIDC 重定向流程
---
## 三、SSO 中心说明
### 3.1 目录结构
```
usercenter/
├── app.py # FastAPI 主服务
├── sso_config.py # 共享配置(SECRET_KEY、Cookie 属性等)
├── database.py # SQLAlchemy + SQLite
├── models.py # User 表
├── static/
│ └── login.html # 登录页
├── sso.db # 用户数据库(自动生成)
└── docs/ # 文档
```
### 3.2 关键 API
| 接口 | 方法 | 说明 |
|------|------|------|
| `/` | GET | 登录页,已登录则跳转 redirect 参数地址 |
| `/api/login` | POST | 表单登录,种 Cookie,返回 JSON |
| `/api/logout` | POST | 清除 Cookie |
| `/api/userinfo` | GET | 读取 Cookie 返回 `{"username":"admin"}` |
| `/api/validate` | GET | 同 userinfo,供业务系统可选调用 |
| `/api/change-password` | POST | 修改密码,成功后清 Cookie 要求重新登录 |
### 3.3 登录流程时序
```
用户 业务前端 SSO 中心 业务后端
│ │ │ │
│ 点击"SSO登录" │ │ │
│───────────────────>│ │ │
│ │ 跳转 :9000/ │ │
│ │ ?redirect=回调地址 │ │
│ │───────────────────>│ │
│ │ │ │
│ 输入账号密码 │ │ │
│───────────────────────────────────────>│ │
│ │ │ │
│ │ POST /api/login │ │
│ │ (表单提交) │ │
│ │───────────────────>│ │
│ │ │ 验证密码 │
│ │ │ 签发 JWT │
│ │ │ 种 Cookie │
│ │ │ ◄────────────────── │
│ │ 302 跳转回业务前端 │ │
│ │◄───────────────────│ │
│ │ │ │
│ 浏览器自动带Cookie访问业务页面 │ │
│───────────────────>│ │ │
│ │ AJAX withCredentials│ │
│ │────────────────────────────────────────>│
│ │ │ 读Cookie→验JWT │
│ │ │ ← 返回用户信息 │
│ │◄────────────────────────────────────────│
```
---
## 四、新项目接入步骤
### 步骤 1:复制 SSO 验签模块
把 `sso_client.py` 复制到新项目后端目录:
```python
# sso_client.py — 与 SSO 中心共享 SECRET_KEY
from fastapi import Request, HTTPException
from jose import jwt, JWTError
SECRET_KEY = "your-secret-key-change-in-production" # 必须与 sso_config.py 一致
ALGORITHM = "HS256"
def get_current_user_from_cookie(request: Request) -> dict:
"""从请求 Cookie 中读取 access_token,本地验签后返回用户信息"""
token = request.cookies.get("access_token")
if not token:
return None
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if not username:
return None
return {"username": username}
except JWTError:
return None
```
**⚠️ SECRET_KEY 必须与 `usercenter/sso_config.py` 完全一致,否则验签失败。**
### 步骤 2:后端接入认证
根据项目情况选择模式:
#### 模式 A:纯 SSO 认证(新项目推荐)
项目本身无独立登录体系,完全依赖 SSO Cookie:
```python
from fastapi import FastAPI, Request, HTTPException, Depends
from sso_client import get_current_user_from_cookie
app = FastAPI()
def require_sso(request: Request) -> str:
"""SSO 必须登录,返回 username"""
user = get_current_user_from_cookie(request)
if not user:
raise HTTPException(401, "未登录,请先访问 SSO 中心登录")
return user["username"]
# 无需登录的接口
@app.get("/api/public")
def public_api():
return {"msg": "所有人可访问"}
# 需要登录的接口
@app.post("/api/protected")
def protected_api(username: str = Depends(require_sso)):
return {"msg": f"你好, {username}"}
@app.delete("/api/protected/{id}")
def delete_api(id: str, username: str = Depends(require_sso)):
return {"msg": f"{username} 删除了 {id}"}
```
**实际案例:** `www/server.py` 就是这种模式,`require_sso` 作为 `Depends` 注入到写操作接口。
#### 模式 B:双模式认证(已有独立登录的项目)
项目有自己本地账号体系,SSO 作为额外入口:
```python
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from sso_client import get_current_user_from_cookie
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
async def get_current_user(
request: Request,
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
):
"""双模式认证:Bearer Token 优先 → SSO Cookie 兜底"""
username = None
# 模式一:原有 Bearer Token(本地登录)
if token:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
except JWTError:
pass
# 模式二:SSO Cookie
if not username:
sso_user = get_current_user_from_cookie(request)
if sso_user:
username = sso_user.get("username")
if not username:
raise HTTPException(401, "无法验证凭据")
# 查本地用户表
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(401, "用户不存在")
return user
```
**实际案例:** `personal-blog/backend/routers/auth.py` 的 `get_current_admin` 就是这种模式。
**双模式要点:**
- `OAuth2PasswordBearer` 必须设 `auto_error=False`,无 Header 时返回 `None` 而非抛异常
- SSO 用户必须在本地用户表中有对应记录(如 blog 的 Admin 表),否则查不到会 401
- 退出登录时同时清除本地 token 和 SSO Cookie:
```python
@router.post("/logout")
def logout(response: Response):
response.delete_cookie("access_token", domain="129.211.173.87", path="/")
return {"message": "退出成功"}
```
### 步骤 3:配置 CORS
后端必须允许 SSO 中心域名的跨域请求,且必须开启 `allow_credentials=True`:
```python
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://129.211.173.87", # nginx 反代
"http://129.211.173.87:8888", # blog 前端
"http://129.211.173.87:9000", # SSO 中心
"http://129.211.173.87:XXXX", # ← 新项目前端端口
],
allow_credentials=True, # ← 必须!否则 Cookie 不会随跨域请求发送
allow_methods=["*"],
allow_headers=["*"],
)
```
**⚠️ `allow_origins` 不能用 `["*"]`,与 `allow_credentials=True` 互斥,浏览器会拒绝。**
### 步骤 4:前端接入
#### 4.1 AJAX 配置(必须开 withCredentials)
```javascript
// axios 示例
const api = axios.create({
baseURL: 'http://129.211.173.87:XXXX/api',
withCredentials: true, // ← 关键!跨域请求携带 Cookie
timeout: 10000,
})
// 如果项目有本地 token,仍然通过 Header 发送(双模式)
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
```
**实际案例:** `personal-blog/frontend/src/api/config.js`
#### 4.2 添加 SSO 登录按钮
```javascript
const handleSSOLogin = () => {
const redirect = window.location.origin + '/admin' // 登录后回跳地址
window.location.href = `http://129.211.173.87:9000/?redirect=${encodeURIComponent(redirect)}`
}
// JSX
<Button onClick={handleSSOLogin}>🔐 SSO 统一登录</Button>
```
#### 4.3 自动检测 SSO 登录状态
页面加载时,如果本地无 token,尝试用 SSO Cookie 调后端接口判断是否已登录:
```javascript
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
navigate('/admin')
return
}
// 无本地 token,尝试 SSO Cookie
api.get('/auth/me')
.then(() => navigate('/admin'))
.catch(() => { /* SSO 也未登录,留在登录页 */ })
}, [])
```
#### 4.4 纯前端页面(无构建工具)的 SSO 检测
如果项目是纯 HTML(如 www/portal),直接用 `fetch` + `credentials`:
```javascript
async function checkSSO() {
try {
const res = await fetch('http://129.211.173.87:XXXX/api/userinfo', {
credentials: 'include' // ← 等同于 axios 的 withCredentials
})
if (res.ok) {
const data = await res.json()
return data.username
}
} catch (e) {}
return null
}
```
### 步骤 5:SSO 中心注册新项目的 CORS
编辑 `usercenter/app.py`,在 CORS `allow_origins` 中添加新项目前端地址:
```python
allow_origins=[
"http://129.211.173.87:8888",
"http://129.211.173.87:9000",
"http://129.211.173.87", # nginx
"http://129.211.173.87:XXXX", # ← 新项目
],
```
修改后重启 SSO 中心:
```bash
sudo systemctl restart usercenter
```
### 步骤 6:配置 systemd 服务
```ini
# /etc/systemd/system/your-project.service
[Unit]
Description=Your Project Backend
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/nanocode/your-project
ExecStart=/usr/bin/python3 app.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable your-project
sudo systemctl start your-project
```
---
## 五、Cookie 配置详解
### 5.1 当前生产配置
```python
# usercenter/sso_config.py
SECRET_KEY = "your-secret-key-change-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15 # JWT 有效期 15 分钟
# Cookie 属性
COOKIE_DOMAIN = "129.211.173.87" # 绑定 IP,跨端口共享
COOKIE_SAMESITE = "Lax" # HTTP 下只能用 Lax
```
### 5.2 Cookie 种植方式(SSO 中心)
```python
cookie_opts = {
"key": "access_token",
"value": jwt_token,
"domain": "129.211.173.87", # 关键:绑定 IP 而非端口
"path": "/", # 全站可用
"httponly": True, # JS 无法读取,防 XSS
"samesite": "Lax", # 同站点跨端口自动携带
"max_age": 15 * 60, # 15 分钟
}
response.set_cookie(**cookie_opts)
```
### 5.3 SameSite 三种模式对比
| 值 | 同站跨端口 | 跨站链接跳转 | 跨站 AJAX | 要求 |
|-----|-----------|------------|----------|------|
| Strict | ✅ | ❌ | ❌ | 无 |
| **Lax** | ✅ | ✅(GET) | ❌ | 无 |
| None | ✅ | ✅ | ✅ | 必须配 Secure,即 HTTPS |
**我们的场景**:同 IP 跨端口 = 同站,`Lax` 足够。跨站 AJAX(如 SSO 中心调业务后端)走的是服务端到服务端,Cookie 在浏览器端随页面请求携带,不涉及跨站 AJAX。
---
## 六、已接入项目清单
| 项目 | 前端端口 | 后端端口 | 认证模式 | 备注 |
|------|---------|---------|---------|------|
| personal-blog | 8888 | 8889 | 双模式(Bearer + Cookie) | 保留原有本地登录 |
| www (Portal) | 80 (nginx) | 8887 | 纯 SSO Cookie | 读写分离,读无需登录 |
| usercenter (SSO) | 9000 | 9000 | 自身 | 登录/注销/改密 |
---
## 七、常见问题排查
### Q1: 后端返回 401 "无法验证凭据",但浏览器能看到 Cookie
**原因**:SECRET_KEY 不一致,JWT 验签失败。
**排查**:
```bash
# 检查 SSO 中心的 SECRET_KEY
grep SECRET_KEY /home/ubuntu/nanocode/usercenter/sso_config.py
# 检查业务后端的 SECRET_KEY
grep SECRET_KEY /home/ubuntu/nanocode/your-project/sso_client.py
```
两边必须完全一致。
### Q2: Cookie 没有随跨域请求发送
**排查清单**:
1. 后端 CORS 是否 `allow_credentials=True`?
2. 后端 CORS `allow_origins` 是否包含前端地址?(不能用 `*`)
3. 前端 axios 是否 `withCredentials: true`?(fetch 用 `credentials: 'include'`)
4. Cookie 的 `domain` 是否设为 `129.211.173.87`?
### Q3: SSO 登录成功,跳转回来后 Cookie 丢了
**原因**:Cookie `domain` 设成了 `localhost` 或带端口的地址。
**正确**:`domain="129.211.173.87"`(纯 IP,不含端口)
### Q4: 退出 SSO 后,业务系统仍然显示已登录
**原因**:
- 双模式项目:清了 SSO Cookie 但 localStorage 里还有 Bearer token
- 解决:退出时同时清两边的凭证
```javascript
// 前端退出逻辑
async function logout() {
localStorage.removeItem('token')
// 同时调 SSO 退出,清除 Cookie
await fetch('http://129.211.173.87:9000/api/logout', {
method: 'POST',
credentials: 'include'
})
window.location.href = '/login'
}
```
### Q5: JWT 过期后如何处理?
当前 JWT 有效期 15 分钟,过期后 `jose.jwt.decode` 抛 `JWTError`,后端返回 401。
前端应在 401 拦截器中跳转 SSO 重新登录:
```javascript
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
// 跳转 SSO 登录
const redirect = window.location.origin + '/admin'
window.location.href = `http://129.211.173.87:9000/?redirect=${encodeURIComponent(redirect)}`
}
return Promise.reject(error)
}
)
```
如果 SSO Cookie 也过期,SSO 中心会展示登录页;如果仍有效,会自动跳转回业务页面(秒级体验)。
---
## 八、安全建议(生产环境)
1. **SECRET_KEY**:当前是占位值 `your-secret-key-change-in-production`,上线前必须改为强随机字符串(≥32 字符),且所有项目同步更新
2. **HTTPS**:公网部署建议启用 HTTPS,Cookie 可升级为 `SameSite=None; Secure`
3. **JWT 有效期**:当前 15 分钟偏短,可根据业务调整(SSO 登录页有"记住我"空间)
4. **账号同步**:SSO 用户必须存在于业务系统本地表中,建议通过脚本或初始化逻辑保证
5. **CORS 白名单**:只放实际需要的前端地址,不要用通配符
---
## 九、快速接入 Checklist
新项目接入时,按此清单逐项确认:
- [ ] 复制 `sso_client.py` 到项目后端,SECRET_KEY 与 SSO 中心一致
- [ ] 后端选择认证模式(纯 SSO / 双模式),实现 `Depends` 注入
- [ ] 后端 CORS 添加 `allow_credentials=True` + 前端地址白名单
- [ ] 前端 axios 配置 `withCredentials: true`(或 fetch 用 `credentials: 'include'`)
- [ ] 前端添加 "SSO 统一登录" 按钮,跳转 `:9000/?redirect=回调地址`
- [ ] 前端 401 拦截器处理 SSO 重定向
- [ ] SSO 中心 CORS 白名单添加新项目前端地址
- [ ] 配置 systemd 服务确保后端持久运行
- [ ] curl 端到端测试:`curl -v -b jar http://129.211.173.87:XXXX/api/xxx`
- [ ] 浏览器端到端测试:点击 SSO 登录 → 跳转 → 回跳 → 接口正常
---
*最后更新:2026-05-05,基于 usercenter / personal-blog / www 三个项目的实际代码整理。*