This commit is contained in:
Ubuntu
2026-07-03 16:58:28 +08:00
commit 32b43f85bd
6240 changed files with 1987678 additions and 0 deletions
+547
View File
@@ -0,0 +1,547 @@
# 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 三个项目的实际代码整理。*
+130
View File
@@ -0,0 +1,130 @@
# 单台服务器多端口 SSO 方案
## 场景约束
- 单台服务器,公网 IP `129.211.173.87`,无域名
- 所有系统通过 `IP:端口` 访问
- 已安装 Redis,不需要 HTTPS(纯 HTTP)
- 目标项目:`personal-blog`(FastAPI 后端 + React 前端)
## 核心原理
同 IP 下不同端口属于 **same-site**,浏览器会自动携带该 IP 下的所有 Cookie。
因此:
- SSO 中心登录后种下 Cookie(Domain 设为 IP 地址)
- 各业务系统同 IP 不同端口均可读取该 Cookie
- 无需 OAuth2 跳转,无需重定向
> 不同端口之间发 AJAX 仍属于 **cross-origin**,需要 CORS 配置 `allow_credentials=True`。
## 架构
```
┌─────────────────────────────────────────────────────────┐
│ 129.211.173.87 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 业务前端 │ │ 业务后端 │ │ SSO中心 │ │
│ │ :5173 │ │ :8889 │ │ :9000 │ │
│ │ React+Vite │ │ FastAPI │ │ FastAPI │ │
│ │ axios │ │ 验签Cookie │ │ 签发JWT │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └──────────────────┴──────────────────┘ │
│ 共享 IP 的 Cookie │
└─────────────────────────────────────────────────────────┘
```
## 数据流
### 登录
```
1. 用户访问 blog 前端 (:5173),未登录,跳转 SSO 登录页 (:9000)
2. SSO 登录页提交用户名密码到 :9000/api/login
3. 验证通过后,SSO 种下两个 Cookie:
- access_token (JWT, 15分钟)
- refresh_token (随机串, Redis 存 7天)
4. 跳转回 blog 前端 (:5173)
5. blog 前端 axios 请求 blog 后端,自动携带 Cookie
6. blog 后端从 Cookie 读 access_token,本地验签,返回数据
```
### 登出
```
1. 前端调用 :9000/api/logout
2. SSO 清除 Cookie,Redis 删除 refresh_token
3. 业务系统再请求时 Cookie 已空,返回 401
```
## Cookie 设计
| 属性 | 值 | 说明 |
|------|-----|------|
| Name | `access_token` / `refresh_token` | |
| Domain | `129.211.173.87` | 同 IP 共享 |
| Path | `/` | 全站可用 |
| HttpOnly | `true` | 防 XSS |
| SameSite | `Lax` | 同 IP 不同端口算 same-site,Lax 足够;HTTP 下不能用 None+Secure |
| MaxAge | 900s / 604800s | 15分钟 / 7天 |
## SSO 中心实现清单
- `app.py` — FastAPI 主应用,端口 9000
- `database.py` — SQLite 用户库
- `models.py` — User 模型
- `sso_config.py` — SECRET_KEY / Redis 配置
- `static/login.html` — 独立登录页
- `requirements.txt`
接口:
- `POST /api/login` — 登录,种 Cookie
- `POST /api/logout` — 登出,清 Cookie
- `GET /api/userinfo` — 读 Cookie 返回当前用户
- `GET /api/validate` — 读 Cookie 返回验证结果(供业务系统可选调用)
- `GET /` — 返回登录页
## 业务系统接入修改(personal-blog)
**原则:改最小,不动原有登录**
### 后端(`backend/`)
1. **新增 `sso_client.py`**
- 提供 `get_current_user_from_cookie()` 函数
- 从请求 Cookie 读 `access_token`
- 用 SSO 相同的 SECRET_KEY 本地验签(HS256)
- 返回 `{"username": "..."}`
2. **修改 `routers/auth.py`**
- `get_current_admin` 改为:
- 先尝试原有 `OAuth2PasswordBearer` Header(兼容旧本地登录)
- Header 不存在则尝试 Cookie 中的 SSO token
- SSO token 验签通过后,用 username 查本地 Admin 表
- 查到则返回,查不到则 401
3. **修改 `app.py` CORS**
- `allow_origins` 加入 `http://129.211.173.87:9000`(SSO中心)
- `allow_credentials=True` 保持开启
### 前端(`frontend/src/`)
1. **修改 `api/config.js`**
- axios 增加 `withCredentials: true`(让跨域请求携带 Cookie)
- CORS 请求必须带 Cookie,否则 SSO 种下的 Cookie 无法被后续请求使用
2. **修改 `pages/Login.jsx`**
- 保留现有本地登录表单
- 新增按钮【SSO 统一登录】,点击跳转 `http://129.211.173.87:9000/?redirect=http://129.211.173.87:5173`
- 页面加载时:如果 localStorage 没有 token,先调用 `authApi.getMe()` 试探(后端可能通过 Cookie 已识别用户),若成功则自动进入后台
## 验证步骤
1. 启动 SSO 中心:`python usercenter/app.py` → 监听 :9000
2. 启动 blog 后端:`python personal-blog/backend/app.py` → 监听 :8889
3. 启动 blog 前端:`cd personal-blog/frontend && npm run dev` → :5173
4. 浏览器访问 `http://129.211.173.87:5173`
5. 点击【SSO 统一登录】,在 :9000 输入 admin/admin123 登录
6. 自动跳转回 :5173,进入后台
7. 打开 `http://129.211.173.87:9000/api/userinfo`,应返回已登录用户信息
8. 调用 `http://129.211.173.87:9000/api/logout`,再访问 blog 应返回 401