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