19 KiB
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 |
关键约束:
- HTTP 下不能用
SameSite=None——None要求配Secure属性,而Secure要求 HTTPS,我们没 HTTPS - 同 IP +
SameSite=Lax即可跨端口共享 —— 浏览器把不同端口视为同站点 - 跨顶级域名(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 复制到新项目后端目录:
# 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:
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 作为额外入口:
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:
@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:
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)
// 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 登录按钮
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 调后端接口判断是否已登录:
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:
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 中添加新项目前端地址:
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 中心:
sudo systemctl restart usercenter
步骤 6:配置 systemd 服务
# /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
sudo systemctl daemon-reload
sudo systemctl enable your-project
sudo systemctl start your-project
五、Cookie 配置详解
5.1 当前生产配置
# 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 中心)
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 验签失败。
排查:
# 检查 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 没有随跨域请求发送
排查清单:
- 后端 CORS 是否
allow_credentials=True? - 后端 CORS
allow_origins是否包含前端地址?(不能用*) - 前端 axios 是否
withCredentials: true?(fetch 用credentials: 'include') - 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
- 解决:退出时同时清两边的凭证
// 前端退出逻辑
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 重新登录:
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 中心会展示登录页;如果仍有效,会自动跳转回业务页面(秒级体验)。
八、安全建议(生产环境)
- SECRET_KEY:当前是占位值
your-secret-key-change-in-production,上线前必须改为强随机字符串(≥32 字符),且所有项目同步更新 - HTTPS:公网部署建议启用 HTTPS,Cookie 可升级为
SameSite=None; Secure - JWT 有效期:当前 15 分钟偏短,可根据业务调整(SSO 登录页有"记住我"空间)
- 账号同步:SSO 用户必须存在于业务系统本地表中,建议通过脚本或初始化逻辑保证
- 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 三个项目的实际代码整理。