Files
nanocode/usercenter/docs/sso-integration-guide.md
T
2026-08-04 16:00:01 +08:00

19 KiB
Raw Blame History

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 复制到新项目后端目录:

# 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
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 自身 登录/注销/改密

七、常见问题排查

原因: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

两边必须完全一致。

排查清单:

  1. 后端 CORS 是否 allow_credentials=True?
  2. 后端 CORS allow_origins 是否包含前端地址?(不能用 *)
  3. 前端 axios 是否 withCredentials: true?(fetch 用 credentials: 'include')
  4. Cookie 的 domain 是否设为 129.211.173.87?

原因: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 中心会展示登录页;如果仍有效,会自动跳转回业务页面(秒级体验)。


八、安全建议(生产环境)

  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 三个项目的实际代码整理。