DMS 系统 SSO 单点登录完整技术方案

前后端集成实现详解

项目名称 DMS 环境监测系统 软件版本 V3.1.5
模块名称 OAuth2.0 单点登录集成(前端 + 后端)
文档版本 V2.0(完整版) 文档日期 2026-04-20
技术栈 前端:Vue 3 + Vue Router + Axios + Ant Design Vue
后端:Django 4.x + Django REST Framework + MySQL + JWT

1. 方案概述

本方案详细描述了 DMS 系统如何与 C大脑门户实现 OAuth2.0 单点登录(SSO)集成的前端实现细节。采用业界标准的 OAuth2.0 授权码模式(Authorization Code Grant),确保身份认证的安全性、标准化和可维护性。

核心特点:
  • 动态配置:所有 SSO 参数从后端接口动态获取,无需重启前端服务
  • 智能路由:根据用户类型(SSO/传统)自动选择登录方式
  • 无感续期:Token 过期后 SSO 用户自动跳转至认证中心重新认证
  • 双重兼容:保留传统账号密码登录,作为应急备份通道
  • 安全机制:支持 State 参数防 CSRF 攻击,密钥数据库存储

2. 整体架构设计

2.1 技术架构图

用户访问 DMS
路由守卫检测
未登录?
跳转 C大脑认证
 
C大脑返回 Code
前端回调页面
调用后端接口
获取 JWT Token
 
保存 Token
标记 SSO 用户
跳转首页

2.2 核心文件清单

文件路径 功能说明 关键职责
src/config/axios-config.js Axios 拦截器配置 Token 自动注入、401 错误处理、SSO 重定向逻辑
src/config/setting.js 全局配置文件 白名单路由、Token 存取方法、用户信息管理
src/router/index.js 路由配置与守卫 OAuth2 回调路由注册、登录状态判断、动态路由加载
src/views/station/oauth/callback.vue OAuth2 回调页面 接收 Code、调用后端登录接口、保存 Token、标记 SSO 用户
src/views/login/login.vue 传统登录页面 账号密码登录入口(应急备份)

3. 核心流程详解

3.1 首次 SSO 登录流程

步骤 1:用户访问受保护路由

当用户直接访问 DMS 系统或 Token 过期时,触发路由守卫逻辑。

// src/router/index.js - 路由守卫 router.beforeEach((to, from, next) => { NProgress.start(); updateTitle(to); // 判断是否登录 if (setting.takeToken()) { // 已登录,检查动态路由 if (!store.state.user.menus) { store.dispatch('user/getMenus').then(({menus, home}) => { router.addRoute({ path: '/', component: EleLayout, redirect: setting.homePath || home, children: menuToRoutes(menus, (component) => import('@/views' + component)) }); next({...to, replace: true}); }).catch(() => { next(); }); } else { next(); } } else if (setting.whiteList.includes(to.path)) { // 白名单路由,直接放行 next(); } else if (to.path === '/oauth2/callback/') { // 【关键】OAuth2 回调页,直接放行让页面处理 Code next(); } else if (to.query.code) { // 其他页面带了 code,跳转到回调页 next('/oauth2/callback/'); } else { // 未登录且无 code,跳转到传统登录页 next({path: '/login', query: to.path === '/' ? {} : {from: to.path}}); } });

步骤 2:判断是否需要 SSO 登录

在传统登录页面或业务逻辑中,可以主动触发 SSO 登录。

注意:当前版本中,用户需要手动点击"SSO 登录"按钮或通过特定入口触发。未来可以优化为自动检测并跳转。

步骤 3:构建 OAuth2 授权 URL

调用 redirectToSSO() 函数,动态从后端获取配置并构造授权 URL。

// src/config/axios-config.js - redirectToSSO 函数 async function redirectToSSO() { // 清除本地标记和 Token await store.dispatch('user/removeToken'); localStorage.removeItem('is_sso_user'); try { // 【动态获取】从后端接口获取最新的 SSO 配置 const res = await axios.get('/cbrain/detail'); if (res.data.code === 0 && res.data.data) { const config = res.data.data; // 直接使用数据库中配置的完整授权地址 const authorizeUrl = config.authorize_url; const redirectUri = config.redirect_uri; // 构造 OAuth2 参数字符串 const paramsStr = `response_type=code&client_id=${config.client_id}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid,userinfo`; // 【严格匹配客户文档】将参数拼在 # 后面 let finalAuthUrl = ''; if (authorizeUrl.includes('?')) { // 如果地址已有查询参数使用&连接 finalAuthUrl = `${authorizeUrl}&${paramsStr}`; } else { // 如果地址没有查询参数使用?连接 finalAuthUrl = `${authorizeUrl}?${paramsStr}`; } console.log('正在重定向至 SSO 认证中心:', finalAuthUrl); window.location.href = finalAuthUrl; } else { console.error('获取 SSO 配置失败,回退到密码登录'); location.replace('/login'); } } catch (error) { console.error('重定向至 SSO 提供商异常:', error); location.replace('/login'); } }
URL 拼接逻辑说明:
  • 场景 1:authorize_url = http://example.com/path?param1=value1 → 使用 & 连接
  • 场景 2:authorize_url = http://example.com/path → 使用 ? 连接
  • 这种设计确保了无论管理员如何配置 URL,都能生成合法的 OAuth2 授权链接

步骤 4:浏览器跳转至 C大脑认证中心

执行 window.location.href = finalAuthUrl,浏览器跳转到 C大脑登录页面。

// 最终生成的 URL 示例 http://10.230.8.92/brain-platform/#/Oauth?response_type=code&client_id=b3240ffe2ed640e09b38c5b968a96d02&redirect_uri=http%3A%2F%2F192.168.0.75%2Foauth2%2Fcallback%2F&scope=openid,userinfo

步骤 5:用户在 C大脑完成认证

用户在 C大脑门户输入账号密码(或已登录),C大脑验证通过后,携带 code 参数重定向回 DMS 的回调地址。

// C大脑重定向回 DMS https://192.168.0.75/oauth2/callback/?code=wzxk2LhzaGsMrrYND9fK7NKLkWLLP8BSElD27Zme3urgiyfTP4BdozaZaRgP

3.2 OAuth2 回调处理流程

步骤 6:回调页面接收 Code

Vue Router 匹配到 /oauth2/callback/ 路由,加载回调组件。

<!-- src/views/station/oauth/callback.vue --> <template> <div class="oauth-callback-container"> <div class="loading-box"> <a-spin tip="正在验证身份..." /> </div> </div> </template> <script> export default { name: 'OAuthCallback', async created() { console.log('=== OAuth 回调页面已加载 ==='); console.log('当前完整 URL:', window.location.href); // 【双重保险】优先用 Vue Router,如果拿不到再尝试原始解析 let code = this.$route.query.code; if (!code) { const urlParams = new URLSearchParams(window.location.search); code = urlParams.get('code'); } console.log('最终获取到的 Code:', code); if (!code) { this.$message.error('授权失败:缺少必要参数 (Code)'); console.error('错误原因:URL 中未检测到 code 参数'); return; } try { // 从后端接口获取最新的 SSO 配置 const configRes = await this.$http.get('/cbrain/detail'); if (configRes.data.code !== 0 || !configRes.data.data) { this.$message.error('获取 SSO 配置失败'); return; } const cbrainConfig = configRes.data.data; // 从 redirect_uri 中提取后端 API 路径 const redirectUri = cbrainConfig.redirect_uri; let callbackPath = new URL(redirectUri).pathname; // 【关键】确保路径以斜杠结尾,避免 Django APPEND_SLASH 报错 if (!callbackPath.endsWith('/')) { callbackPath += '/'; } // 调用后端接口完成登录(使用 axios baseURL + 相对路径) const res = await this.$http.post(callbackPath, { code }); if (res.data.code === 0) { // 保存 Token (注意:Store 需要传入对象格式) this.$store.commit('user/SET_TOKEN', { token: res.data.data.token, remember: true }); // 保存用户信息 if (res.data.data.userInfo) { this.$store.commit('user/SET', { key: 'user', value: res.data.data.userInfo }); } // 【关键】标记当前为 SSO 登录用户,用于 Token 过期后的自动重定向 localStorage.setItem('is_sso_user', 'true'); this.$message.success('登录成功'); // 跳转到首页 this.$router.push('/'); } else { // 处理业务错误(如:不在白名单中) this.$message.error(res.data.msg || '登录失败'); } } catch (error) { console.error('OAuth 登录异常:', error); this.$message.error('网络异常或登录服务不可用'); } } } </script>
关键技术点:
  • 双重 Code 提取:优先使用 Vue Router 的 this.$route.query.code,失败时使用原生 URLSearchParams 解析,提高兼容性
  • 动态回调路径:从数据库配置的 redirect_uri 中提取 pathname,确保与后端路由一致
  • 斜杠处理:强制确保回调路径以 / 结尾,避免 Django 的 APPEND_SLASH 机制导致 301 重定向丢失 POST 数据
  • SSO 标记:设置 localStorage.setItem('is_sso_user', 'true'),这是后续智能续期的关键

3.3 Token 自动续期流程

步骤 7:Token 过期检测与智能重定向

当用户访问受保护接口时,如果 Token 过期(后端返回 401),Axios 响应拦截器会捕获并判断用户类型。

// src/config/axios-config.js - 响应拦截器 axios.interceptors.response.use((res) => { // 登录过期处理 if (res.data.code === 401) { if (res.config.url === setting.menuUrl) { goLogin(); } else { Modal.destroyAll(); // 【方案一】智能重定向:判断是否为 SSO 用户 const isSsoUser = localStorage.getItem('is_sso_user') === 'true'; if (isSsoUser) { // SSO 用户:直接跳回 SSO 提供商重新认证(无感续期) Modal.info({ title: '身份认证过期', content: '请重新进行单点登录认证', okText: '确定', onOk: () => { redirectToSSO(); } }); } else { // 传统用户:跳去密码登录页 Modal.info({ title: '系统提示', content: '登录状态已过期, 请退出重新登录!', okText: '重新登录', onOk: () => { goLogin(true); } }); } } return Promise.reject(new Error(res.data.msg)); } // token自动续期 const access_token = res.headers['x-new-token']; if (access_token) { setting.cacheToken(access_token); } return res; }, (error) => { return Promise.reject(error); });
智能续期优势:
  • SSO 用户:点击"确定"后自动跳转至 C大脑,由于 C大脑会话仍然有效,用户无需再次输入密码,实现"无感续期"
  • 传统用户:跳转到本地登录页,需要重新输入账号密码
  • 用户体验:大幅减少 SSO 用户的重复登录操作,提升满意度

4. 配置管理

4.1 前端配置文件

4.1.1 白名单路由配置

// src/config/setting.js export default { // 不需要登录的路由(OAuth2 回调路径需与数据库配置一致) whiteList: ['/login', '/forget', '/oauth2/callback/'], // token存储的名称 tokenStoreName: 'access_token', // 用户信息存储的名称 userStoreName: 'user', // ... 其他配置 }
重要说明:
/oauth2/callback/ 必须加入白名单,否则路由守卫会拦截并重定向到登录页,导致无法接收 Code 参数。

4.1.2 Token 管理方法

// src/config/setting.js - Token 存取方法 export default { /** * 获取缓存的token的方法 * @returns {string} */ takeToken() { let token = localStorage.getItem(this.tokenStoreName); if (!token) { token = sessionStorage.getItem(this.tokenStoreName); } return token; }, /** * 缓存token的方法 * @param token * @param remember 是否永久存储 */ cacheToken(token, remember) { localStorage.removeItem(this.tokenStoreName); sessionStorage.removeItem(this.tokenStoreName); if (token) { if (remember) { localStorage.setItem(this.tokenStoreName, token); } else { sessionStorage.setItem(this.tokenStoreName, token); } } } }

4.2 后端配置接口

前端通过 GET /cbrain/detail 接口动态获取 SSO 配置,该接口返回以下字段:

字段名 类型 说明 示例值
authorize_url String C大脑授权地址(完整 URL) http://10.230.8.92/brain-platform/#/Oauth
token_url String Token 交换接口地址 http://.../cbrain-auth/oauth2/token
userinfo_url String 用户信息接口地址 http://.../cbrain-auth/oauth2/userInfo
client_id String 应用标识 b3240ffe2ed640e09b38c5b968a96d02
client_secret String 应用密钥(仅后端使用) 不返回给前端
redirect_uri String 回调地址(完整 URL) https://192.168.0.75/oauth2/callback/
is_active Boolean SSO 功能是否启用 true
安全提醒:
client_secret 绝对不能暴露给前端!前端只需要 authorize_urlclient_idredirect_uri 即可构建授权 URL。Token 交换必须在后端完成。

5. 关键技术点

5.1 路由守卫策略

场景 条件判断 处理方式
已登录且有菜单 setting.takeToken()store.state.user.menus 存在 直接放行 next()
已登录但无菜单 setting.takeToken()!store.state.user.menus 获取菜单并动态添加路由,然后 next({...to, replace: true})
访问白名单 setting.whiteList.includes(to.path) 直接放行 next()
OAuth2 回调页 to.path === '/oauth2/callback/' 直接放行 next(),让页面自己处理 Code
其他页面带 Code to.query.code 存在 重定向到回调页 next('/oauth2/callback/')
未登录且无 Code 以上条件都不满足 跳转到传统登录页 next({path: '/login', ...})

5.2 URL 参数编码

在构建 OAuth2 授权 URL 时,redirect_uri 必须进行 URL 编码:

// 正确做法 const redirectUri = config.redirect_uri; // https://192.168.0.75/oauth2/callback/ const paramsStr = `redirect_uri=${encodeURIComponent(redirectUri)}`; // 结果:redirect_uri=https%3A%2F%2F192.168.0.75%2Foauth2%2Fcallback%2F // 错误做法(会导致参数解析失败) const paramsStr = `redirect_uri=${redirectUri}`; // 结果:redirect_uri=https://192.168.0.75/oauth2/callback/ ❌

5.3 LocalStorage vs SessionStorage

存储方式 生命周期 适用场景 SSO 中的使用
localStorage 永久存储,除非手动清除 "记住我"功能 存储 is_sso_user 标记、长期有效的 Token
sessionStorage 浏览器关闭后自动清除 临时会话 短期 Token(未勾选"记住我"时)

5.4 回调路径斜杠处理

常见陷阱:
Django 默认开启 APPEND_SLASH,如果回调路径不以 / 结尾,会发生 301 重定向,导致 POST 请求变成 GET 请求,Code 参数丢失!
// 解决方案:强制确保路径以斜杠结尾 let callbackPath = new URL(redirectUri).pathname; if (!callbackPath.endsWith('/')) { callbackPath += '/'; } // 例如:/oauth2/callback → /oauth2/callback/

6. 异常处理机制

6.1 前端异常场景

异常场景 触发条件 处理方式 用户提示
缺少 Code 参数 回调 URL 中没有 code 参数 显示错误消息,停留在回调页 "授权失败:缺少必要参数 (Code)"
获取配置失败 /cbrain/detail 接口返回错误或网络异常 回退到传统登录页 控制台输出错误日志
后端登录失败 后端返回 code !== 0 显示后端返回的错误消息 例如:"工号 XXX 未在 DMS 授权名单中"
网络异常 调用后端接口时发生网络错误 捕获异常并提示 "网络异常或登录服务不可用"
Token 过期 后端返回 401 状态码 弹出模态框,根据用户类型选择重定向方式 SSO 用户:"身份认证过期";传统用户:"登录状态已过期"

6.2 降级策略

多层降级保障:
  1. 第一层:SSO 配置获取失败 → 回退到传统登录页
  2. 第二层:SSO 登录失败 → 用户仍可使用账号密码登录
  3. 第三层:Token 续期失败 → 提示用户重新登录

7. 安全性设计

7.1 安全措施清单

安全措施 实现方式 防护目标
密钥隔离 client_secret 仅存储在后端数据库,不暴露给前端 防止密钥泄露
HTTPS 传输 生产环境强制使用 HTTPS 协议 防止中间人攻击
一次性 Code OAuth2 Code 只能使用一次,使用后失效 防止重放攻击
Token 自动刷新 后端返回 x-new-token Header,前端自动更新 延长会话有效期
CSRF 防护 支持 State 参数校验(需在授权 URL 中添加) 防止跨站请求伪造
白名单机制 后端校验用户工号是否在本地数据库中存在 防止未授权访问
待优化项:
当前代码中尚未实现 State 参数的生成和校验。建议在 redirectToSSO() 函数中添加随机 State 参数,并在回调时验证,以增强 CSRF 防护能力。

8. 测试建议

8.1 功能测试用例

测试场景 测试步骤 预期结果
正常 SSO 登录 1. 清除本地 Token
2. 触发 SSO 登录
3. 在 C大脑完成认证
成功跳转回 DMS,显示首页,LocalStorage 中有 is_sso_user=true
Token 过期续期 1. 模拟 Token 过期(后端返回 401)
2. 点击"确定"重新认证
跳转至 C大脑,自动完成认证(无感续期),返回 DMS 后恢复正常
白名单拦截 使用未在 DMS 注册的工号登录 C大脑 后端返回错误,前端显示"工号 XXX 未在 DMS 授权名单中"
配置获取失败 模拟 /cbrain/detail 接口返回 500 错误 前端回退到传统登录页,控制台输出错误日志
缺少 Code 参数 直接访问 /oauth2/callback/(不带 code) 显示"授权失败:缺少必要参数 (Code)"
传统登录兼容 使用账号密码登录 登录成功,is_sso_user 不被设置或为 false

8.2 兼容性测试

  • 浏览器兼容:Chrome、Firefox、Edge、Safari 最新版本
  • URL 格式兼容:测试带参数和不带参数的 authorize_url 配置
  • 回调路径兼容:测试带斜杠和不带斜杠的 redirect_uri 配置

9. 部署注意事项

9.1 环境变量配置

# .env.production VUE_APP_API_BASE_URL=https://dms.samc.intra/api VUE_APP_SYSTEM_NAME=DMS 环境监测系统 BASE_URL=/

9.2 Nginx 配置要点

# Nginx 配置示例 location /oauth2/callback/ { # 确保回调路径正确代理到后端 proxy_pass http://backend_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 前端静态资源 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # Vue Router History 模式必需 }
关键配置:
Vue Router 使用 History 模式时,Nginx 必须配置 try_files $uri $uri/ /index.html;,否则刷新页面会出现 404 错误。

9.3 数据库配置检查

确保 application_cbrain 表中的配置正确:

  • authorize_url:必须是完整的 C大脑授权地址,包含协议和路径
  • redirect_uri:必须与 C大脑后台配置的回调地址完全一致(包括协议、域名、端口、路径)
  • client_id:从 C大脑运维团队获取的正确应用 ID
  • is_active:设置为 true 启用 SSO 功能

10. 常见问题排查

10.1 Code 参数丢失

问题现象:回调页面显示"缺少必要参数 (Code)"

可能原因:
  1. Nginx 配置错误,导致 URL 参数被截断
  2. Django APPEND_SLASH 导致 301 重定向,POST 变 GET
  3. C大脑配置的 redirect_uri 与实际不一致
解决方案:
  • 检查浏览器地址栏,确认 URL 中包含 ?code=xxx
  • 检查 Nginx 日志,确认没有发生意外的重定向
  • 确保回调路径以 / 结尾

10.2 CORS 跨域错误

问题现象:控制台显示 CORS 错误,无法调用后端接口

解决方案:
  • 确保后端 Django 配置了正确的 CORS_ALLOWED_ORIGINS
  • 开发环境下,检查 vue.config.js 中的代理配置
  • 生产环境下,确保前后端同源或通过 Nginx 统一入口

10.3 Token 无法保存

问题现象:登录成功后刷新页面,又回到登录页

可能原因:
  1. 浏览器禁用了 Cookie 或 LocalStorage
  2. Token 存储逻辑有误
  3. 路由守卫判断条件错误
解决方案:
  • 打开浏览器开发者工具,检查 Application → Local Storage 中是否有 access_token
  • 检查 setting.cacheToken() 方法是否正确调用
  • 检查 setting.takeToken() 是否能正确读取 Token

11. 后端技术方案详解

11.1 后端架构概览

DMS 系统的 SSO 后端采用 Django 4.x 框架,基于 OAuth2.0 授权码模式实现与 C大脑门户的单点登录集成。后端主要负责配置管理、Token 交换、用户匹配和会话建立等核心功能。

11.1.1 核心模块结构

模块名称 文件路径 职责说明
OAuth2 应用 application/oauth2/ 处理 OAuth2 回调、Token 交换、用户匹配等核心认证逻辑
CBrain 配置应用 application/cbrain/ 管理 SSO 配置参数(Client ID、Secret、URLs 等)
User 模型扩展 application/user/models.py 扩展用户表字段,支持 SSO 用户标识和审计
JWT 工具 utils/jwts.py 生成和验证本地 JWT Token
事件日志 utils/event_logger.py 记录 SSO 登录事件到系统日志表

11.1.2 后端技术栈

  • Web 框架:Django 4.x + Django REST Framework
  • 数据库:MySQL 8.0+
  • HTTP 客户端:Requests 库(用于调用 C大脑 API)
  • 认证机制:JWT (JSON Web Token)
  • 日志系统:Python logging + 自定义事件日志
  • 部署方式:Waitress (Windows) / Gunicorn (Linux) + Nginx

11.2 数据库设计

11.2.1 CBrain 配置表(application_cbrain)

该表存储 SSO 集成的所有配置参数,支持动态修改而无需重启服务。

字段名 类型 必填 默认值 说明
id Integer 1 主键,固定为 1(单例配置)
sso_base_url Varchar(255) https://cbrain.comac.intra SSO 提供商的基础域名
authorize_url Varchar(500) 完整 URL OAuth2 授权地址(完整路径)
token_url Varchar(500) 完整 URL Token 交换接口地址
userinfo_url Varchar(500) 完整 URL 用户信息接口地址
client_id Varchar(100) b3240ffe... OAuth2 应用 ID
client_secret Varchar(255) qDyfReC6... OAuth2 应用密钥(敏感信息)
redirect_uri Varchar(500) 完整 URL OAuth2 回调地址(必须与 C大脑后台配置一致)
is_active Boolean False 保留字段,当前架构下 SSO 始终启用
updated_at DateTime 自动 auto_now 最后更新时间(自动更新)
updated_by Varchar(50) NULL 最后修改配置的用户名
设计亮点:
  • 单例模式:通过 get_or_create(id=1) 确保只有一条配置记录
  • 动态加载:每次请求都从数据库读取最新配置,修改后立即生效
  • 完整 URL:所有端点 URL 都是完整路径,避免拼接错误
  • 审计追踪:记录更新时间和更新人,便于问题追溯

11.2.2 User 用户表扩展字段

为了支持 SSO 集成,在原有的 application_user 表中新增了以下字段:

字段名 类型 索引 说明
source Varchar(20) 用户来源标识
可选值:local(本地创建)、sso(SSO单点登录)、import(批量导入)、api(API同步)、other(其他)
默认值:local
cbrain_user_id Varchar(50) C大脑工号(如:AI0074)
用于匹配 C大脑返回的 userId
可为空,SSO 登录后自动填充
oauth_openid Varchar(100) C大脑用户的唯一标识 OpenID
用于后续可能的用户信息同步
可为空,SSO 登录后自动填充
last_sso_login DateTime 记录用户最后一次通过 C大脑 SSO 登录的时间
用于审计和用户行为分析
可为空
迁移注意:
这些字段通过 Django Migration 文件 0002_user_cbrain_user_id_user_last_sso_login_and_more.py 自动添加,无需手动执行 SQL。

11.3 核心业务流程

11.3.1 配置管理流程

(1)获取配置接口

接口地址:GET /api/cbrain/detail

权限要求:无需登录(公开接口,供前端构建授权 URL 使用)

# application/cbrain/views.py class CBrainDetailView(View): def get(self, request): # 调用查询参数详情方法 data = services.CBrainDetail() # 返回结果 return R.ok(data=data)
# application/cbrain/services.py def CBrainDetail(): """查询 SSO/OAuth2 配置详情""" cbrain_obj = CBrain.objects.filter(id=1).first() if not cbrain_obj: return None data = { 'sso_base_url': cbrain_obj.sso_base_url, 'authorize_url': cbrain_obj.authorize_url, 'token_url': cbrain_obj.token_url, 'userinfo_url': cbrain_obj.userinfo_url, 'client_id': cbrain_obj.client_id, 'client_secret': cbrain_obj.client_secret, # ⚠️ 注意:生产环境应脱敏 'redirect_uri': cbrain_obj.redirect_uri, 'is_active': True, # SSO 始终启用(双轨制架构) 'updated_at': cbrain_obj.updated_at.strftime('%Y-%m-%d %H:%M:%S') if cbrain_obj.updated_at else '', 'updated_by': cbrain_obj.updated_by or '', } return data
安全提醒:
当前实现中,client_secret 会返回给前端!虽然前端不使用该字段,但存在安全风险。
建议优化:CBrainDetail() 函数中移除或脱敏 client_secret 字段,仅在后端内部使用。
(2)更新配置接口

接口地址:PUT /api/cbrain/update

权限要求:需要登录 + 权限标识 sys:cbrain:update

# application/cbrain/views.py @method_decorator(check_login, name='put') class CBrainUpdateView(PermissionRequired, View): permission_required = ('sys:cbrain:update',) def put(self, request): if IS_DEMO_MODE: return R.failed("演示环境,暂无操作权限") result = services.CBrainUpdate(request) return result

请求体示例:

{ "form": { "sso_base_url": "https://cbrain.comac.intra", "authorize_url": "https://cbrain.comac.intra/brain-platform/#/Oauth", "token_url": "https://cbrain.comac.intra/cbrain-gateway/cbrain-auth-server/cbrain-auth/oauth2/token", "userinfo_url": "https://cbrain.comac.intra/cbrain-gateway/cbrain-auth-server/cbrain-auth/oauth2/userInfo", "client_id": "b3240ffe2ed640e09b38c5b968a96d02", "client_secret": "qDyfReC6OdonsGfgy2RVdyAB", "redirect_uri": "https://dms.samc.intra/api/oauth2/callback/", "is_active": true }, "signInfo": { "username": "admin", "comment": "更新生产环境 SSO 配置" } }

11.3.2 OAuth2 回调处理流程

(1)回调接口定义

接口地址:POST /api/oauth2/callback/

权限要求:无需登录(白名单路由)

# application/oauth2/urls.py from django.urls import path from . import views urlpatterns = [ path('', views.OAuth2CallbackView.as_view()), # 注意:末尾有斜杠 ]
(2)回调视图实现
# application/oauth2/views.py class OAuth2CallbackView(View): """ C大脑 OAuth2 登录回调视图 接收前端传来的 code,换取 token 并生成本地 JWT 仅支持 POST 方法(由前端主动调用) """ def post(self, request): try: # 1. 解析请求参数 data = json.loads(request.body) code = data.get('code') if not code: logger.warning("OAuth2 回调缺少 code 参数") return R.failed('缺少授权码') logger.info(f"收到 OAuth2 回调请求,code: {code[:20]}...") # 2. 用 Code 换取 Access Token token_data = OAuth2Service.exchange_code_for_token(code) if not token_data: logger.error("换取 Token 失败,可能是 Code 已失效或配置错误") return R.failed('认证服务响应异常或 Code 已失效') access_token = token_data.get('access_token') if not access_token: return R.failed('获取 Access Token 失败') # 3. 本地白名单校验与登录处理(兼容标准流程和特殊流程) user = OAuth2Service.handle_local_login(token_data) # 4. 生成 DMS 系统的本地 JWT jwt_token = create_token({'userId': user.id}) logger.info(f"用户 {user.username} 通过 C大脑 SSO 登录成功") return R.ok(data={ 'token': jwt_token, 'userInfo': { 'id': user.id, 'realname': user.realname, } }) except PermissionError as e: logger.warning(f"SSO 登录被拒绝: {str(e)}") return R.failed(str(e)) except Exception as e: logger.exception(f"SSO 登录处理发生未知错误: {e}") return R.failed(f'登录处理失败: {str(e)}')
(3)Token 交换服务(核心逻辑)

这是整个 SSO 流程中最关键的部分,实现了双重兼容机制

# application/oauth2/services.py - exchange_code_for_token 方法 @staticmethod def exchange_code_for_token(code): """用 Code 换取 Access Token(兼容标准和特殊流程)""" try: config = OAuth2Service._get_config() # 准备 Token 交换参数 token_params = { 'grant_type': 'authorization_code', 'client_id': config['client_id'], 'client_secret': config['client_secret'], 'code': code, 'redirect_uri': config['redirect_uri'] } # 【方案 A:标准 OAuth2.0 规范】POST 请求 + Form Body logger.info("[方案 A] 正在尝试标准 OAuth2.0 方式 code 换取 Token (POST Body)") resp = requests.post(config['token_url'], data=token_params, timeout=10) logger.info(f"[方案 A] 响应状态码: {resp.status_code}") # 【兼容性检查】如果标准方式返回 400/405,尝试【方案 B:客户特定实现】 if resp.status_code in [400, 405]: logger.warning("[方案 A] 失败,自动切换至 [方案 B] 兼容模式 (POST with URL Params)...") from urllib.parse import urlencode full_url_b = f"{config['token_url']}?{urlencode(token_params)}" logger.info(f"[方案 B] 目标 URL (含参): {full_url_b}") resp = requests.post(config['token_url'], params=token_params, timeout=10) logger.info(f"[方案 B] 响应状态码: {resp.status_code}") if resp.status_code == 200: result = resp.json() logger.info(f"C大脑 Token 接口原始响应数据: {result}") # 提取关键字段 access_token = result.get('access_token') user_id = result.get('userId') # C大脑特殊字段 openid = result.get('openid') if not access_token: logger.error(f"C大脑响应中缺少 access_token: {result}") return None # 返回标准化的字典 token_data = { 'access_token': access_token, 'userId': user_id, # 可能为 None(标准流程) 'openid': openid } # 【新增兼容性】如果 Token 响应中没有 userId,尝试调用 UserInfo 接口获取 if not user_id: logger.info("Token 响应中未包含 userId,尝试调用 UserInfo 接口获取用户信息...") user_info = OAuth2Service.get_user_info(access_token) if user_info: logger.info(f"成功从 UserInfo 接口获取用户信息: {user_info}") # 从用户信息中提取 userId(兼容多种字段名) user_id = user_info.get('id') or user_info.get('userId') or user_info.get('account_no') if user_id: token_data['userId'] = user_id logger.info(f"从 UserInfo 接口提取到 userId: {user_id}") # 如果 UserInfo 返回了 openid,也补充进来 if not openid: token_data['openid'] = user_info.get('openid') else: logger.error(f"UserInfo 接口返回的数据中未找到有效的 userId 字段: {user_info}") return None else: logger.error("调用 UserInfo 接口失败,无法获取用户标识") return None logger.info(f"最终获取的 Token 数据: access_token={access_token[:20]}..., userId={user_id}, openid={openid}") return token_data else: logger.error(f"Token 交换失败,状态码: {resp.status_code}, 响应: {resp.text}") return None except Exception as e: logger.error(f"OAuth2 换取 Token 失败: {e}") return None
双重兼容机制说明:
  1. Token 接口兼容:先尝试标准 POST Body 方式,失败后自动切换到 URL 参数方式
  2. 用户标识获取兼容:优先从 Token 响应中获取 userId,如果没有则调用 UserInfo 接口
  3. UserInfo 接口兼容:先尝试 POST 请求,失败后切换到 GET + Bearer Token 方式
  4. 字段名兼容:支持多种 userId 字段名(iduserIdaccount_no
(4)UserInfo 接口调用
# application/oauth2/services.py - get_user_info 方法 @staticmethod def get_user_info(access_token): """获取用户详细信息(兼容多种认证服务器实现)""" try: config = OAuth2Service._get_config() if not config.get('userinfo_url'): logger.warning("未配置 userinfo_url,跳过用户信息获取") return None logger.info(f"正在调用 UserInfo 接口: {config['userinfo_url']}") # 【方案 A】尝试 POST 请求(C大脑的实现方式) logger.info("[UserInfo 方案 A] 尝试 POST 请求") resp = requests.post( config['userinfo_url'], data={'accessToken': access_token}, timeout=10 ) # 如果 POST 失败,尝试【方案 B】GET 请求 + Authorization Header(标准 OAuth2.0) if resp.status_code in [400, 401, 405]: logger.warning(f"[UserInfo 方案 A] 失败 (状态码: {resp.status_code}),尝试 [方案 B] GET + Bearer Token") headers = { 'Authorization': f'Bearer {access_token}', 'Accept': 'application/json' } resp = requests.get(config['userinfo_url'], headers=headers, timeout=10) logger.info(f"[UserInfo 方案 B] 响应状态码: {resp.status_code}") if resp.status_code == 200: result = resp.json() logger.info(f"UserInfo 接口原始响应: {result}") # 处理不同的响应格式 # 格式1: {"code": 200, "data": {...}} (C大脑风格) # 格式2: {...} (标准 OAuth2.0) user_data = result.get('data') if 'data' in result else result if user_data and isinstance(user_data, dict): return user_data else: logger.warning(f"UserInfo 接口返回的数据格式异常: {result}") return result else: logger.error(f"UserInfo 接口调用失败,状态码: {resp.status_code}, 响应: {resp.text}") return None except Exception as e: logger.error(f"OAuth2 获取用户信息失败: {e}", exc_info=True) return None
(5)本地用户匹配与登录
# application/oauth2/services.py - handle_local_login 方法 @staticmethod def handle_local_login(token_data): """处理本地用户匹配与登录(兼容标准和特殊流程)""" # 从 token_data 中获取 userId(工号) user_id = token_data.get('userId') if not user_id: raise PermissionError("认证服务器未返回有效的用户标识 (userId)。请检查 Token 或 UserInfo 接口响应。") logger.info(f"开始处理本地登录,userId: {user_id}") # 通过 username (工号) 查找本地用户 try: user = User.objects.get(username=user_id) logger.info(f"成功匹配本地用户: {user.username} (ID: {user.id})") except User.DoesNotExist: logger.warning(f"工号 {user_id} 未在 DMS 授权名单中") raise PermissionError(f"工号 {user_id} 未在 DMS 授权名单中。请联系管理员。") # 检查用户状态 if user.status != 1: logger.warning(f"账户 {user.username} 已被禁用") raise PermissionError("该账户已被禁用,请联系管理员") # 同步更新 SSO 相关字段 changed = False # 更新 C大脑工号 if user.cbrain_user_id != user_id: user.cbrain_user_id = user_id changed = True # 更新 OpenID openid = token_data.get('openid') if openid and user.oauth_openid != openid: user.oauth_openid = openid changed = True # 记录 SSO 登录时间 user.last_sso_login = datetime.datetime.now() changed = True # 标记用户来源为 SSO if user.source != 'sso': user.source = 'sso' changed = True # 如果有变更,保存用户信息 if changed: user.save(update_fields=['cbrain_user_id', 'oauth_openid', 'last_sso_login', 'source']) logger.info(f"已更新用户 {user.username} 的 SSO 相关信息") # 记录 SSO 登录事件 now_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") event_msg = f"用户 {user.username} 通过SSO单点登录" create_sys_event( strdatetime=now_time, type=9, # 事件类型:SSO 登录 opt_username='system', event=event_msg ) logger.info(f"SSO 登录事件已记录: {event_msg}") # 更新用户登录次数(与本地登录保持一致) user.login_times += 1 user.save(update_fields=['login_times']) return user
白名单机制说明:
系统采用工号白名单策略:只有当 C大脑返回的 userId(工号)在本地 application_user 表中存在时,才允许登录。
这确保了即使通过了 C大脑认证,未经 DMS 管理员授权的用户仍然无法访问系统。

11.4 配置管理界面

DMS 系统提供了可视化的 SSO 配置管理界面,管理员可以在后台动态修改 SSO 参数。

11.4.1 配置项说明

配置项 填写说明 示例值
SSO门户地址 C大脑的基础域名 https://cbrain.comac.intra
SSO授权地址 完整的 OAuth2 授权页面 URL https://cbrain.comac.intra/brain-platform/#/Oauth
Token接口地址 用于用 code 换取 access_token 的接口 https://.../cbrain-auth/oauth2/token
用户信息接口地址 用于获取用户详细信息的接口 https://.../cbrain-auth/oauth2/userInfo
Client ID 从 C大脑运维团队获取的应用 ID b3240ffe2ed640e09b38c5b968a96d02
Client Secret 从 C大脑运维团队获取的应用密钥 (敏感信息,不展示)
OAuth2回调地址 DMS 接收 Code 的回调 URL(必须与 C大脑后台配置完全一致) https://dms.samc.intra/api/oauth2/callback/
配置修改注意事项:
  • 修改配置后立即生效,无需重启 Django 服务(因为每次都从数据库读取)
  • redirect_uri 必须与 C大脑后台配置的回调地址完全一致(包括协议、域名、端口、路径、斜杠)
  • 生产环境和开发环境的 redirect_uri 不同,请注意区分
  • 修改配置后建议进行一次完整的 SSO 登录测试

11.5 日志与审计

11.5.1 日志级别设计

日志级别 记录内容 示例
INFO 正常流程的关键节点 “收到 OAuth2 回调请求”、“用户 XXX 通过 C大脑 SSO 登录成功”
WARNING 异常情况但不影响流程 “工号 XXX 未在 DMS 授权名单中”、“账户 XXX 已被禁用”
ERROR 严重错误导致流程中断 “Token 交换失败,状态码: 401”、“调用 UserInfo 接口失败”
DEBUG 详细的调试信息(生产环境关闭) “向C大脑code换Token返回的有用数据: {...}”

11.5.2 系统事件记录

每次 SSO 登录成功后,系统会在 application_sysevent 表中记录一条事件:

# 事件记录示例 { "strdatetime": "2026-04-20 14:30:25", "type": 9, // 事件类型:SSO 登录 "opt_username": "system", "event": "用户 AI0074 通过SSO单点登录" }

这些事件可用于:

  • 安全审计:追踪谁在何时通过 SSO 登录
  • 问题分析:排查登录失败的原因
  • 使用统计:分析 SSO 登录的使用频率

11.6 安全性设计

11.6.1 安全措施清单

安全措施 实现方式 防护目标
密钥数据库存储 client_secret 存储在数据库中,不在代码中硬编码 防止源码泄露导致密钥暴露
动态配置加载 每次请求都从数据库读取最新配置 配置修改后立即生效,减少重启风险
白名单机制 校验 C大脑返回的 userId 是否在本地用户表中存在 防止未授权用户访问系统
用户状态校验 检查本地用户的 status 字段是否为 1(启用) 禁用的账户即使通过 SSO 认证也无法登录
一次性 Code OAuth2 Code 只能使用一次,C大脑服务端会使其失效 防止重放攻击
HTTPS 传输 生产环境强制使用 HTTPS 协议 防止中间人攻击和数据窃听
JWT Token 使用 JWT 作为本地会话令牌,设置合理的过期时间 无状态认证,便于水平扩展
请求超时控制 所有 HTTP 请求设置 timeout=10 防止请求挂起导致资源耗尽
待优化安全项:
  1. State 参数:当前未实现 State 参数校验,建议添加以增强 CSRF 防护
  2. client_secret 脱敏/cbrain/detail 接口不应返回 client_secret 给前端
  3. 速率限制:建议对 /oauth2/callback/ 接口添加速率限制,防止暴力破解
  4. IP 白名单:可考虑限制只有 C大脑服务器的 IP 才能调用回调接口

11.7 部署与运维

11.7.1 环境变量配置

DMS 项目使用 .env 文件管理环境变量,SSO 相关配置如下:

# .env.production DJANGO_SETTINGS_MODULE=application.settings DATABASE_URL=mysql://user:password@localhost:3306/dms_db SECRET_KEY=your-django-secret-key-here DEBUG=False ALLOWED_HOSTS=dms.samc.intra,localhost # SSO 相关(可选,主要用于调试) SSO_ENABLED=True LOG_LEVEL=WARNING # 生产环境建议使用 WARNING 级别

11.7.2 数据库迁移

SSO 相关的数据库表通过 Django Migration 自动创建:

# 执行数据库迁移 python manage.py migrate # 这会创建/更新以下表: # - cbrain(SSO 配置表) # - application_user(扩展 SSO 字段) # - application_sysevent(事件日志表)

11.7.3 初始配置

首次部署时,需要在数据库中初始化 SSO 配置:

# 方法 1:通过 Django Shell python manage.py shell >>> from application.cbrain.models import CBrain >>> config = CBrain.get_config() # 自动创建默认配置 >>> print(config.client_id) # 查看默认值 # 方法 2:通过管理后台 # 访问 http://your-domain/admin/cbrain/cbrain/ # 编辑 id=1 的记录,填入实际的 SSO 配置参数

11.7.4 Nginx 配置要点

# Nginx 配置示例 server { listen 443 ssl; server_name dms.samc.intra; # SSL 证书配置 ssl_certificate /etc/nginx/ssl/dms.crt; ssl_certificate_key /etc/nginx/ssl/dms.key; # 前端静态资源 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # Vue Router History 模式必需 } # 后端 API 代理 location /api/ { proxy_pass http://127.0.0.1:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # OAuth2 回调接口(确保正确代理) location /api/oauth2/callback/ { proxy_pass http://127.0.0.1:8000/oauth2/callback/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 重要:保持 POST 方法和请求体 proxy_method POST; proxy_pass_request_body on; } }
Nginx 配置关键点:
  • 确保 /api/oauth2/callback/ 路径正确代理到后端
  • 保持 POST 方法和请求体不被丢弃
  • 如果使用 Django 的 APPEND_SLASH,确保 URL 末尾有斜杠

11.7.5 常见问题排查

问题 1:Token 交换失败(401 Unauthorized)
可能原因:
  • client_idclient_secret 配置错误
  • code 已过期或被使用过(一次性)
  • redirect_uri 与 C大脑后台配置不一致
解决方案:
  • 检查数据库中 cbrain 表的配置是否正确
  • 查看后端日志中的详细错误信息
  • 联系 C大脑运维团队确认应用配置
问题 2:用户不在白名单中
现象:提示“工号 XXX 未在 DMS 授权名单中”

原因:C大脑返回的 userId 在本地 application_user 表中不存在

解决方案:
  • 在 DMS 管理后台创建对应用户,username 字段填写工号(如:AI0074)
  • 确保用户状态为“启用”(status=1
  • 重新进行 SSO 登录
问题 3:回调路径 301 重定向
现象:浏览器地址栏显示 301 重定向,POST 请求变成 GET,Code 丢失

原因:Django 的 APPEND_SLASH 机制导致路径补斜杠

解决方案:
  • 确保前端回调路径以 / 结尾:/oauth2/callback/
  • 确保后端 URL 配置也有斜杠:path('', views.OAuth2CallbackView.as_view())
  • 检查 Nginx 配置,确保没有额外的重定向规则

12. 前后端集成总结

12.1 完整流程图

第一阶段:授权发起

  1. 用户访问 DMS → 前端路由守卫检测未登录
  2. 前端调用 GET /api/cbrain/detail 获取 SSO 配置
  3. 前端构建 OAuth2 授权 URL,跳转到 C大脑

第二阶段:C大脑认证

  1. 用户在 C大脑输入账号密码(或已登录)
  2. C大脑验证通过,生成一次性 Code
  3. C大脑重定向回 DMS:/oauth2/callback/?code=xxx

第三阶段:后端处理

  1. 前端回调页面提取 Code,调用 POST /api/oauth2/callback/
  2. 后端用 Code 换取 Access Token(兼容两种请求方式)
  3. 如果 Token 响应无 userId,调用 UserInfo 接口获取(兼容 POST/GET)
  4. 后端根据 userId(工号)在本地用户表中查找
  5. 校验用户状态,更新 SSO 相关字段
  6. 记录登录事件,生成本地 JWT Token
  7. 返回 JWT Token 和用户信息给前端

第四阶段:前端会话建立

  1. 前端保存 JWT Token 到 LocalStorage/SessionStorage
  2. 设置 localStorage.setItem('is_sso_user', 'true')
  3. 跳转到首页,加载动态菜单

第五阶段:Token 续期

  1. 用户访问受保护接口,Token 过期(后端返回 401)
  2. 前端 Axios 拦截器检测到 401,判断 is_sso_user
  3. SSO 用户:弹出提示,点击确定后跳转 C大脑重新认证(无感续期)
  4. 传统用户:跳转到本地登录页重新输入密码

12.2 关键技术点对比

技术点 前端实现 后端实现
配置管理 调用 /cbrain/detail 接口动态获取 从数据库 cbrain 表实时读取
URL 构建 判断是否已有 ?,选择 &? 连接 不涉及(前端负责)
Code 接收 Vue Router $route.query.code + 原生解析兜底 接收前端 POST 请求的 JSON body
Token 交换 不涉及(后端完成) 双重兼容:POST Body → URL Params
用户标识获取 不涉及(后端完成) 优先 Token 响应,降级 UserInfo 接口
用户匹配 不涉及(后端完成) 根据 userId 查询本地 username 字段
会话建立 保存 JWT 到 Storage,设置 is_sso_user 标记 生成 JWT Token,更新用户 SSO 字段
Token 续期 Axios 拦截器检测 401,智能重定向 返回 401 状态码,触发前端续期逻辑

12.3 优势与特点

架构优势:
  • 双重兼容:同时支持 C大脑特殊实现和标准 OAuth2.0 流程
  • 动态配置:修改 SSO 参数无需重启服务,立即生效
  • 双轨登录:保留传统账号密码登录,作为应急备份
  • 智能续期:SSO 用户 Token 过期后无感续期,提升用户体验
  • 白名单机制:严格的工号校验,确保只有授权用户可访问
  • 完整审计:记录所有 SSO 登录事件,支持安全审计
  • 多层降级:配置获取失败→传统登录;SSO 失败→密码登录

13. 未来优化方向

13.1 短期优化(高优先级)

  • 添加 State 参数:在授权 URL 中增加随机 State 参数,回调时验证,增强 CSRF 防护
  • client_secret 脱敏/cbrain/detail 接口不再返回 client_secret 给前端
  • 自动 SSO 检测:在登录页增加“使用 C大脑账号登录”按钮,或直接检测企业内网环境自动跳转 SSO
  • 错误日志上报:将前端异常上报到监控系统,便于快速定位问题
  • 速率限制:对 /oauth2/callback/ 接口添加速率限制,防止暴力破解

13.2 中期优化(中优先级)

  • 静默续期:在 Token 即将过期前,自动发起续期请求,避免用户感知
  • 多 SSO 提供商支持:抽象 OAuth2 接口,支持同时接入多个认证中心(如钉钉、企业微信)
  • 用户信息同步:定期从 C大脑同步用户信息(姓名、部门、职位等)
  • IP 白名单:限制只有 C大脑服务器的 IP 才能调用回调接口
  • 性能优化:缓存 SSO 配置(带 TTL),减少数据库查询次数

13.3 长期优化(低优先级)

  • OIDC 标准支持:升级为 OpenID Connect 协议,获取更多标准化用户信息
  • 移动端适配:优化移动端 SSO 登录体验,支持 App 唤起
  • 审计日志增强:记录所有 SSO 登录事件到独立的审计表,支持高级查询和报表
  • 双因素认证:SSO 登录后增加二次验证(短信验证码、TOTP 等)
  • 会话管理:实现统一的会话管理中心,支持强制下线、单点登出等功能

文档版本:V2.0(完整版) | 编制日期:2026-04-20

编制单位:杭州依谦科技有限公司 | 项目名称:DMS 环境监测系统

本文档为内部技术文档,未经许可不得外传。
如有疑问,请联系项目开发团队。
文档范围:涵盖前端(Vue 3)+ 后端(Django)完整 SSO 集成方案

环境监测

PM2.5/10、温湿度、有害气体等实时监测