548 lines
19 KiB
Markdown
548 lines
19 KiB
Markdown
# 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 / zf9wx4xm7` |
|
||
|
||
**关键约束:**
|
||
|
||
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 三个项目的实际代码整理。*
|