☰
Vue跨域单点登录实战:Token安全传递与postMessage桥接方案
2026/10/1 12:37:30 网站建设 项目流程

前阵子公司内部要打通两套业务系统的登录状态,A系统是主后台,跑在admin.company.com上,B系统是后来上的一个数据看板,跑在dashboard.company.com。需求很简单:用户在公司主后台登录后,点一个链接跳到数据看板,看板不能再让用户重新输入一遍账号密码,最好全程无感、自动带上登录身份。这个需求听起来就是典型的“单点登录”,但真正落地的时候,我才发现两个不同域名下的 Vue 项目互相跳转、还要安全传递 Token,中间有不少坑。这篇文章就把我完整踩过一遍之后的方案、代码和避坑经验写出来,给同样要做跨域 SSO 的同学参考。

先交代一下背景:两个项目都用 Vue 2 / Vue 3 搭建,前端各自独立部署,后端有一套统一的认证中心负责发 Token。Token 用的是 JWT 格式,登录成功后前端把 Token 存在 localStorage 里,后续每个接口请求都在 header 里带Authorization: Bearer <token>。这种架构在同一个域名下很简单,但一旦域名不同,事情就变了。因为浏览器同源策略的限制,localStorage不能跨域名读取,Cookie的Domain属性也不能直接设到另一个完全不同的域名上,所以 Token 从一个系统带到另一个系统,必须先解决“跨域传输”这个基础问题。

这篇文章适合谁看?如果你正在做多系统统一登录、前端跳转传 Token、或者被跨域认证折腾得头疼,那这篇的内容应该能帮你省下不少排查时间。我会把需求分析、方案选型、完整代码、常见问题全部整理出来,代码你直接拿过去改改域名就能用。

1. 先把需求拆清楚:看似是“跳转”,实际是“跨域传身份”

1.1 一个单点登录需求的真实业务背景

我们的场景具体是这样的:主后台 A 系统是所有内部用户的登录入口,B 系统是后来单独开发的报表平台。B 系统并没有独立的账号体系,它的用户完全依赖 A 系统的登录态。也就是说,用户必须在 A 系统登录成功后,携带自己的身份凭证去访问 B 系统,B 系统才能识别“你是谁、有没有权限看这些数据”。

这种需求在用友、泛微 OA、金蝶这类企业级系统集成里特别常见。比如很多公司会把 OA 系统和财务系统、报表系统做单点登录打通,用户只需要在 OA 登录一次,点菜单进入其他系统时就不再重复登录。我这次做的就类似这种“前端单点登录 + Token 透传”。热搜词里也提到了“泛微oa系统单点登录金蝶”,说明大家确实经常遇到 OA 与业务系统之间做免登跳转的需求。

单点登录的本质,不是简单的“页面跳转”,而是“身份凭证的跨系统共享”。A 系统验证了用户身份,产生了一个凭据,B 系统要信任这个凭据,并把它转换为自己的会话。如果把“身份凭证”和“传输方式”分开看,问题就清晰多了。

1.2 不同域名到底难在哪里

先说说为什么“两个不同域名”会带来麻烦。浏览器有一个最基础的安全模型叫“同源策略”,协议、域名、端口三者完全一致,才叫同源。admin.company.com和dashboard.company.com这两个地址,虽然共享同一个二级域名company.com,但它们的子域名不同,浏览器依然认为是不同的源。

同源策略限制了四件重要的事:第一,localStorage、sessionStorage严格按源隔离,A 域名存的数据,B 域名读不到;第二,Cookie虽然可以通过设置Domain=company.com实现子域名共享,但如果两个系统的主域名完全不同(比如a.com和b.com),这条路就走不通;第三,window.opener、window.parent这些跨窗口访问能力被限制,你不能在 A 系统的页面里直接读取 B 系统页面的变量;第四,Ajax跨域请求虽然可以发出去,但默认情况下浏览器不允许你读取响应内容,后端必须配合 CORS 头。

所以,两个 Vue 项目“互相跳转”本身是没问题的,window.location.href想跳就跳,真正的难点在于:跳转之后,目标系统怎么拿到源系统的登录凭证?

1.3 方案选型:为什么不能直接localStorage一把梭

我刚开始的第一反应很简单:A 系统登录成功后,跳转到https://dashboard.company.com/sso?token=xxx,B 系统从 URL 参数里拿 Token,再存到自己的 localStorage。这个方案最快,10 分钟就能跑通。

但冷静下来分析,这个方案有三个明显的隐患。第一,Token 会留在浏览器历史记录里,用户按一下后退键或者看历史记录,Token 直接暴露;第二,如果跳转的目标页面里引用了外部资源,Referer请求头会把带 Token 的完整 URL 泄漏给第三方;第三,后端日志如果记录了完整请求地址,Token 也会被带到日志系统里,安全审计的时候这些都是大忌。

为了在“实现简单”和“安全可靠”之间找一个平衡,我评估了三种方案,最终选了“URL 携带一次性票据 + 后端换取 Token”为主,“iframe + postMessage 静默同步”为辅的组合方案。下表是我当时做的对比:

方案实现难度安全性用户体验适用场景
URL 直接携带 Token最低低好,但历史记录泄漏风险大仅限内网演示、临时调试
iframe + postMessage 同步 Token中中好,可实现静默登录同主域、信任的多个子系统
一次性 ticket 换取 Token较高高需要一次后端交互正式生产环境、多系统集成

具体参数上,一次性 ticket 我建议用 32 位随机字符串,有效期设 5 分钟,只能用一次,换到 Token 立即作废。这样即使 ticket 在 URL 传输过程中被截获,攻击者在短暂时间内拿它换了 Token,这个 ticket 就已经失效,能大大降低 Token 泄漏的风险。

2. 核心设计:用“桥接页”解决跨域消息传递

2.1 三个角色:源系统、目标系统、桥接页

要理解后面整套代码,先得把架构里的三个角色搞清楚。源系统就是用户已经登录的系统,这里指 A;目标系统是用户接下来要访问的系统,这里指 B;桥接页是一个部署在某个公共域名下的静态 HTML 页面,它是整个跨域通信的“中转站”。

为什么需要桥接页?因为 A 的页面和 B 的页面之间不能直接通信,但它们各自都可以和桥接页通信。A 把消息发给桥接页,桥接页再把消息转给 B,消息就能跨域传输了。这个思路跟现实中打电话需要总机转接是一样的,A 分机不能直接拨通 B 分机,但都能拨通总机。

桥接页怎么工作?它内部其实只有两个核心逻辑:接收消息、转发消息。接收消息时,必须校验消息来源event.origin是否在白名单里;转发消息时,要指定接收方窗口的window.postMessage目标地址。这个页面不需要任何构建工具,一个纯 HTML + 原生 JS 文件就能搞定,放在 Nginx 静态目录下就行。

2.2 为什么选 postMessage,而不是改 Cookie 或 LocalStorage

先说说为什么不用 Cookie。Cookie 实现跨域共享有两种思路:一种是设置Domain为公共父域,但这要求所有系统的域名都在同一个主域下,比如a.company.com和b.company.com可以设Domain=company.com,可如果两个系统是a.com和b.com,就完全没法搞了;另一种是第三方 Cookie,就是桥接页所在域给浏览器种一个 Cookie,其他域通过 iframe 读取,但现在主流浏览器都在收紧第三方 Cookie 的权限,Safari 默认直接拦截,这条路越来越难走。

再说说为什么不能直接共享 localStorage。前面已经说了,localStorage 严格按源隔离,A 域名写入的数据,B 域名从物理层面上就读不到,也没有任何 API 可以跨域操作它。真正能跨域通知对方的,浏览器也提供了标准方案,就是postMessage。

postMessage的优势在于:第一,它是浏览器原生 API,兼容性非常好;第二,它允许跨窗口通信,父页面和 iframe、window.open打开的新窗口都能用;第三,通信时可以携带任意结构的数据,并且接收方可以校验发送方的 origin 合法性。整个方案不用引入任何第三方依赖,实现成本很低。如果你看到这一步觉得有点抽象,可以把它理解成:每个浏览器窗口都有一个“邮局”,postMessage就是寄信,message事件就是收信,跨域不再是被拦截的死胡同,而是需要通过“邮局”中转一下。

2.3 Token 的格式设计:JWT 的长处和短板

前面提到 Token 用的是 JWT,这里多说几句。JWT 由三段组成:Header(算法声明)、Payload(业务数据)、Signature(签名)。服务端用密钥对前两段做签名,客户端拿到 JWT 后无法篡改,因为一旦改了 Payload,签名就验证不过。

JWT 的好处是“无状态”,服务端不需要存 Session,只要验证签名合法、没过期,就信任 Token 里的用户身份。这个特性对跨域 SSO 很友好,因为 A 系统和 B 系统的后端服务可能各自独立部署,它们只需要共享同一个密钥或同一套公钥体系,就能验证同一个用户签发的 Token。

但 JWT 有个短板:一旦签发,在过期之前是没法主动让它失效的。所以实际操作中我做了一个补充机制,用 5 分钟短时效的 ticket 换取一个 2 小时时效的 access_token,同时再发一个 7 天的 refresh_token 用于续签。这样一来,即使 access_token 泄漏,影响范围也能控制在一个较短的时间窗口内。热词里提到 “jwt实现token续签”“token续签”,实际就是在 token 过期后用 refresh_token 换新 token 的流程,后面我会在代码里展示前端怎么处理。

3. 完整实操:从 A 系统登录到 B 系统免登

3.1 准备两个 Vue 项目和一个桥接页

实操部分我先说下环境。A 系统是 Vue 2 + Vue Router 3,B 系统是 Vue 3 + Vue Router 4,两个项目都用hash模式路由。桥接页我部署在了https://sso.company.com/bridge.html,这个域名专门用来放跨域通信页面。如果你没有单独的 sso 域名,挂在 A 系统或 B 系统的域下也不是不行,但建议单独弄一个,逻辑上更干净。

两个项目里都需要用到几个关键依赖:Vue 项目本身是现成的;请求库我用的是axios;Token 解析用jwt-decode(只是前端解析 Payload,验签必须交给后端)。桥接页不需要任何依赖,单 HTML 文件。

部署结构长这样:

https://admin.company.com → A 系统 Vue 应用 https://dashboard.company.com → B 系统 Vue 应用 https://sso.company.com/bridge.html → 桥接页

3.2 A 系统:登录成功后把 ticket 跳转给 B

A 系统的登录流程原本是:用户输入账号密码,后端返回 access_token,前端存 localStorage,然后跳转首页。现在改造成:用户登录成功后,A 系统后端额外签发一个一次性 ticket,前端把这个 ticket 拼在跳转 URL 里,交给 B 系统。

先看 A 系统登录页面,登录成功后的处理代码:

// A 系统,login.vue async function handleLogin(form) { const res = await loginApi(form) // 登录成功后,后端返回: // { accessToken, refreshToken, ssoTicket } localStorage.setItem('access_token', res.accessToken) localStorage.setItem('refresh_token', res.refreshToken) // 判断 URL 上是否带了 source 参数,也就是“从哪里来,回哪里去” const redirectUrl = new URLSearchParams(window.location.search).get('source') if (redirectUrl) { // 把 ticket 跳到目标系统 window.location.href = `${redirectUrl}?sso_ticket=${res.ssoTicket}` } else { window.location.href = '/#/dashboard' } }

这里有个小细节:source参数是用户从 B 系统跳回 A 系统登录时带过来的,登录成功后 A 系统需要知道“该把用户送回哪里”。所以登录接口调用前,我一般会先把window.location.href编码后放进 URL 参数里,保证一次完整的跳转闭环。

A 系统后端生成 ssoTicket 的逻辑,我贴一个 Node.js 的示意代码(实际用的是网关服务,但思路一样):

// A 系统后端,Node.js 示例 const crypto = require('crypto') function createSsoTicket(userId) { const ticket = crypto.randomBytes(16).toString('hex') // ticket 用 Redis 存储,有效期 5 分钟,用过即删 redis.set(`sso_ticket:${ticket}`, userId, 'EX', 300) return ticket }

ticket 有效期设 5 分钟,是因为用户从 A 系统跳转到 B 系统,中间可能经过浏览器启动、页面加载、白屏等待,时间太短容易过期,太长又增加被截获的风险。实测下来 5 分钟够用。

3.3 B 系统:接收 ticket 并向后端换取 Token

B 系统这边,用户被跳到https://dashboard.company.com/?sso_ticket=xxxx之后,Vue 应用加载,在路由守卫里统一处理这个 ticket。

B 系统用的哈希路由,URL 形式是https://dashboard.company.com/#/report?token=xxx吗?实际上这里我踩了个坑:如果 B 系统是 hash 模式,那么 URL 上的 query 参数最好是放在 hash 里面,而不是放在域名后面。因为放在域名后面的参数在 Vue 应用刷新时可能会被后端/静态服务器处理掉,hash 里的参数则完全由前端掌控。所以 A 系统跳转时,目标 URL 应该拼成这样:

https://dashboard.company.com/#/sso/login?sso_ticket=xxxx

但上面第 3.2 节的跳转代码如果直接用window.location.href = \${redirectUrl}?sso_ticket=${res.ssoTicket}`,当redirectUrl是https://dashboard.company.com/#/sso/login时,拼接后得到的是https://dashboard.company.com/#/sso/login?sso_ticket=xxxx`,这是对的,因为哈希后面直接拼接 query 就是 Vue Router 能识别的方式。

B 系统的处理代码放在router.beforeEach拦截器里:

// B 系统,router/index.js router.beforeEach(async (to, from, next) => { const hasToken = localStorage.getItem('access_token') // 如果 URL 上带了 sso_ticket,优先换 Token if (to.query.sso_ticket) { try { const res = await exchangeToken(to.query.sso_ticket) // 后端返回 { accessToken, refreshToken } localStorage.setItem('access_token', res.accessToken) localStorage.setItem('refresh_token', res.refreshToken) // 换完之后立刻清理 URL,避免 ticket 残留历史记录 next({ path: to.path, query: {}, replace: true }) return } catch (e) { // ticket 无效或过期,回源系统重新登录 const source = encodeURIComponent(window.location.href) window.location.href = `https://admin.company.com/#/login?source=${source}` return } } if (!hasToken) { // 没有 token,带着来源跳回 A 系统登录 const source = encodeURIComponent(window.location.href) window.location.href = `https://admin.company.com/#/login?source=${source}` return } next() })

后端换取 Token 的接口示意:

// B 系统后端,Node.js 示例 async function exchangeTicket(req, res) { const { ticket } = req.query const userId = await redis.get(`sso_ticket:${ticket}`) if (!userId) { return res.status(401).json({ code: 401, message: 'ticket无效或已过期' }) } // 一次性使用,立即删除 await redis.del(`sso_ticket:${ticket}`) // 签发新的 Token const accessToken = jwt.sign({ userId }, SECRET, { expiresIn: '2h' }) const refreshToken = jwt.sign({ userId, type: 'refresh' }, SECRET, { expiresIn: '7d' }) res.json({ accessToken, refreshToken }) }

这里最重要的一个理念是:B 系统从 ticket 换到的 Token,是 B 系统自己的 Token,不是 A 系统那个 Token。虽然它们的 userId 是同一个,但 Token 的受众(audience)、密钥体系可以独立。这样做的优势是,A 系统和 B 系统的 token 泄漏不会互相影响,权限边界也更清晰。

3.4 静默同步:iframe + postMessage 实现无感登录

上面那套带 ticket 的方案已经能实现单点登录了,但有一个体验小问题:用户跳转 B 系统,B 系统要用 ticket 换 token,中间有一个接口往返,如果网络慢,用户会看到一下白屏。另外如果用户从 B 系统点链接跳回 A 系统,同样要再来一次 ticket 交换流程,次数多了会有点烦。

所以我额外做了第二套增强方案:用 iframe + postMessage 静默同步登录状态。场景是:用户在 A 系统已经登录,之后无论他直接敲 B 系统的地址、还是从收藏夹打开 B 系统,B 系统只要发现“我本地没 token”,就去桥接页问一句:“A 系统那边登录了吗?如果登录了,把 token 同步给我一份”。

这里需要桥接页配合,因为 A 系统和 B 系统 localStorage 不互通,但它们都可以在页面里嵌一个指向https://sso.company.com/bridge.html的隐藏 iframe。桥接页所在域sso.company.com有自己的 localStorage,A 系统登录后把 token 存一份到桥接页,B 系统再从桥接页读出来。

A 系统登录成功后,额外做这样一件事:

// A 系统,登录成功后触发 token 同步到桥接域 function syncTokenToBridge(token) { return new Promise((resolve, reject) => { const iframe = document.createElement('iframe') iframe.src = 'https://sso.company.com/bridge.html' iframe.style.display = 'none' document.body.appendChild(iframe) iframe.onload = function () { // 向桥接页发送 token iframe.contentWindow.postMessage({ type: 'SET_TOKEN', token: token }, 'https://sso.company.com') resolve() } // 5 秒超时保护 setTimeout(() => reject(new Error('bridge load timeout')), 5000) }) }

B 系统启动时,如果本地没有 token,也嵌入同一个桥接页去拉取:

// B 系统,main.js / router 守卫里调用 function getTokenFromBridge() { return new Promise((resolve) => { const iframe = document.createElement('iframe') iframe.src = 'https://sso.company.com/bridge.html' iframe.style.display = 'none' document.body.appendChild(iframe) iframe.onload = function () { iframe.contentWindow.postMessage({ type: 'GET_TOKEN' }, 'https://sso.company.com') } // 监听桥接页回复 function listener(e) { if (e.origin !== 'https://sso.company.com') return if (e.data && e.data.type === 'RETURN_TOKEN') { window.removeEventListener('message', listener) iframe.remove() resolve(e.data.token || null) } } window.addEventListener('message', listener) }) }

桥接页bridge.html的核心代码:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>SSO Bridge</title> </head> <body> <script> // 白名单,只允许这些源访问桥接页 const ALLOWED_ORIGINS = [ 'https://admin.company.com', 'https://dashboard.company.com' ] window.addEventListener('message', function (e) { // 第一步:校验来源 if (!ALLOWED_ORIGINS.includes(e.origin)) return const data = e.data if (!data || !data.type) return // 第二、三步:处理存储 / 读取 if (data.type === 'SET_TOKEN') { localStorage.setItem('sso_bridge_token', data.token) } if (data.type === 'GET_TOKEN') { const token = localStorage.getItem('sso_bridge_token') // 回传给询问方,必须是 e.source e.source.postMessage({ type: 'RETURN_TOKEN', token: token }, e.origin) } if (data.type === 'CLEAR_TOKEN') { localStorage.removeItem('sso_bridge_token') } }) </script> </body> </html>

这套方案的精髓在于:所有跨域通信都通过event.source原路返回,目标地址用e.origin,避免把消息发到错误的地方。而且桥接页不需要知道“谁在问”,它只需要检查来源是否在白名单里,然后原路把数据返回给询问方。

如果你要问我,什么时候用 ticket 方案、什么时候用 postMessage 方案,我的经验是:只要后端能配合,优先用 ticket 换取 token 的方案,因为 ticket 是后端生成的一次性凭证,可以做失效控制、审计日志;postMessage 方案适合后端联调成本高、或者你需要把登录态实时同步给多个子系统的场景。我自己的项目是两者都做:主流程用 ticket,辅助流程用 postMessage 静默同步,看情况切换。

3.5 登出同步:一次退出,全部都退出

单点登录做了一半,很容易漏了登出。用户如果只在 A 系统退出,B 系统的 localStorage 里还留着 token,下一次打开 B 系统照样能访问,这就是“假退出”。所以登出也走桥接页广播。

A 系统退出时:

// A 系统,logout function logout() { const iframe = document.createElement('iframe') iframe.src = 'https://sso.company.com/bridge.html' iframe.style.display = 'none' document.body.appendChild(iframe) iframe.onload = function () { iframe.contentWindow.postMessage({ type: 'CLEAR_TOKEN' }, 'https://sso.company.com') } // 同时清理本地 localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') }

B 系统也要监听来自桥接页的广播,收到LOGOUT消息后清理本地 token 并跳回登录页。桥接页里加一段广播逻辑:

if (data.type === 'CLEAR_TOKEN') { localStorage.removeItem('sso_bridge_token') // 广播给所有业务系统:请退出登录 ALLOWED_ORIGINS.forEach(origin => { if (origin !== e.origin) { // 需要拿到对应窗口的引用,这里简化处理 e.source.postMessage({ type: 'FORCE_LOGOUT' }, origin) } }) }

这里 e.source 只能回给发送方,真正的广播需要维护一份“窗口注册表”,这是复杂系统的做法。小项目可以简化:登出只清桥接层 token + 当前系统 token,其他系统下次请求接口时后端发现 token 失效,自然会踢回登录页。

4. 常见问题与排查技巧实录

4.1 X-Frame-Options 把 iframe 挡在门外

第一次联调 iframe 方案时,桥接页在 A 系统里死活加载不出来,打开控制台看到报错:Refused to display 'https://sso.company.com/bridge.html' in a frame because it set 'X-Frame-Options' to 'sameorigin'。原因是桥接页所在的服务默认在响应头里加了X-Frame-Options: SAMEORIGIN,浏览器拒绝其他域名的页面内嵌这个 iframe。

解决方法是,给桥接页单独设置响应头,允许特定来源嵌入:

Content-Security-Policy: frame-ancestors 'self' https://admin.company.com https://dashboard.company.com

如果服务器版本支持,建议用 CSP 的frame-ancestors替代X-Frame-Options,因为前者更灵活。注意frame-ancestors和X-Frame-Options同时存在时,浏览器会取两者限制的交集,所以要么去掉旧的响应头,要么直接把两者都写好。我这边的 Nginx 配置如下:

location = /bridge.html { add_header Content-Security-Policy "frame-ancestors 'self' https://admin.company.com https://dashboard.company.com"; add_header X-Frame-Options "ALLOW-FROM https://admin.company.com"; # 旧浏览器兼容,新浏览器忽略 proxy_pass http://some_backend; }

4.2 postMessage 消息发早了:时序问题

另一个高发问题是,桥接页 onload 事件触发后,父页面立刻postMessage,但桥接页内部的message事件监听器还没有注册完成,导致消息丢失。这个问题的本质是:iframe onload 只代表页面加载完了,不代表脚本已经执行到注册监听的代码。

我的解决办法是在桥接页代码里主动通知父页面“我准备好了”:

<script> // bridge.html 内部,脚本解析完成后向父窗口发出 ready 信号 window.addEventListener('DOMContentLoaded', function () { window.parent.postMessage({ type: 'BRIDGE_READY' }, '*') }) </script>

父页面收到BRIDGE_READY后再发送业务消息。如果你不想做这种握手协议,也可以用一个兜底方案:首次发送后 200ms 未收到响应,再重试一次。实际测试中,DOMContentLoaded 的时机基本可靠,推荐用它。

4.3 Token 过期后两套系统不同步

ticket 换到的 access_token 有效期 2 小时,refresh_token 有效期 7 天。A 系统用户一直活跃,token 不断续签;B 系统用户一周没打开,再来访问时 refresh_token 过期,接口返回 401。这时候 B 系统的前端如果只是简单地把用户踢回 A 系统登录页,体验是非常糟糕的,因为用户明明在 A 系统还有登录态。

我的处理是:B 系统收到 401 时,先把请求“缓存起来”,然后尝试从桥接页取 token,如果 bridge 里有有效的 token 就重新换一个再重发请求;如果没有,再跳转 A 系统重新登录。axios 响应拦截器里大概这么写:

// B 系统,axios 拦截器 api.interceptors.response.use( response => response, async error => { if (error.response && error.response.status === 401) { // 尝试从 bridge 拉取 token 继续认证 const bridgeToken = await getTokenFromBridge() if (bridgeToken) { // 这里其实是用 bridge 的 token 换本系统新 token const res = await exchangeToken(bridgeToken) localStorage.setItem('access_token', res.accessToken) error.config.headers.Authorization = `Bearer ${res.accessToken}` return api(error.config) // 重放原请求 } // 否则跳登录 window.location.href = `https://admin.company.com/#/login?source=${encodeURIComponent(window.location.href)}` } return Promise.reject(error) } )

这个机制实现了“如果 A 系统还活着,B 系统就能续上”。第一次实现时容易忽略的是:重放请求时error.config里的 headers 可能已经被 axios 处理过,最好重新设置Authorization,而不是直接改原始 headers。

4.4 开发环境联调:本地起两个端口,跨域怎么调

Vue 项目在本地开发时分别在localhost:8080和localhost:8081启动,这时候就存在端口不同导致的跨域问题。桥接域如果是线上https://sso.company.com,本地页面往线上 iframe 里postMessage是可以的,因为 postMessage 本身就是设计来跨域通信的。但 B 系统在本地调后端换 token 的接口时,CORS 就得注意。

我在开发环境的 Vite 配置里加代理,所有/api开头的请求都代理到后端真实地址:

// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'https://dashboard-api.company.com', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } } })

这里有一个关键点:changeOrigin: true必须设置,因为后端可能根据请求头里的Host判断域名白名单。本地开发时如果 Host 是localhost:8081,后端不认账;代理加上changeOrigin后,后端看到的是目标域名的 Host,就放行了。

4.5 常见问题速查表

现象可能原因解决方式
桥接页加载后 iframe 一片空白X-Frame-Options 或 CSP 限制修改响应头,允许业务域嵌入
postMessage 收不到消息消息发送过早、监听器未注册等 BRIDGE_READY 信号后再发送
ticket 换 token 提示已失效ticket 有效期太短或未一次删除延长到 5 分钟,换完立即删除
B 系统 token 过期后无法自动续上没有做 401 重试逻辑拦截 401,尝试从桥接域重新获取
开发环境接口 403CORS 或 Host 白名单问题配 Vite 代理 + changeOrigin
跳转后 URL 里还有 ticket没有清地址栏参数用 router.replace 清理 query
Vue Router history 模式刷新 404后端没配置 fallbackNginx 配置 try_files 指向 index.html

5. 再说几个安全细节和后续扩展方向

5.1 URL 带 ticket 的三个防护手段

虽然用了“一次性票据”而不是直接在 URL 上带 Token,但票据在 URL 传输过程中依然有被截获的风险。除了前面说的“一次性使用 + 5 分钟过期”,我建议再加三个防护。

第一,跳转前校验source域名白名单。A 系统登录接口收到source参数时,必须确认它属于公司内部系统域名,防止攻击者构造一个恶意地址诱导用户跳转后窃取票据。第二,换票接口要做限流和审计,同一 IP、同一 ticket 的异常重复请求直接拒绝,并记录日志。第三,生产环境开启 HTTPS,避免票据在网络传输中明文暴露。

5.2 Token 存 localStorage 的 XSS 风险

这是个老生常谈的问题。localStorage 存 Token 最大的风险是 XSS:只要页面里被注入了一段恶意脚本,它就能直接读取 localStorage 里的内容,Token 随随便便就被偷走。但从实际工程角度看,纯前端单点登录方案下,Token 不存 localStorage 就没有地方可以跨域存储,所以我采用了分层缓解措施:

对越权敏感的操作,后端额外校验用户权限;对 Token,设置较短的有效期(2 小时),把被窃取后的危害窗口压缩;对页面渲染,开启 CSP 策略,限制脚本只能从白名单域名加载,从根源上降低 XSS 注入概率。另外,不要把用户的敏感信息(手机号、身份证号等)塞进 JWT 的 Payload 里,JWT 的 Payload 只是 Base64 编码,不是加密,谁都能解出来。

如果你想追求更高的安全性,可以考虑把 Token 放进httpOnlyCookie,这样脚本无法读取。但代价是跨域方案要重新设计,因为 Cookie 跨域共享的限制会更多。这个取舍没有标准答案,看你的系统对安全的要求有多高。

5.3 后续扩展:从“前端跳转”升级成真正的统一认证中心

其实我现在这套方案,本质上是“前端做的轻量单点登录”,真正成熟的企业级方案一般是前后端配合的“认证中心模式”。用户的登录请求统一发到认证中心,认证中心验证后回调业务系统,业务系统通过回调地址里的授权码换取 token。行业里常说的 CAS(Central Authentication Service)就是这个思路,热词里也有“cas单点登录搭建”“ldap统一用户认证和单点登录”,指的就是这一类。

如果你们公司后面要接的系统越来越多,建议尽早演进到这种模式:认证中心负责登录页、Token 签发、会话管理,所有业务系统都通过标准协议(如 OAuth2 / OIDC)接入。这样做的好处是,各个业务系统之间完全解耦,新系统上线时只需要对接认证中心,不需要再重复实现一套跳转和换票逻辑。前端代码也可以抽成一个公共的sso-sdknpm 包,业务系统一行代码接入,后续维护成本会低很多。

另外,如果业务里要对接企业微信、钉钉这类第三方身份源,认证中心模式也更方便,只需要在认证中心做一次第三方授权登录的适配,所有下游系统都能享受到免登能力。这个演进方向,是这类功能落地一段时间后我感受最深的一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询