ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue跨域单点登录实战:Token安全传递与postMessage桥接方案

Vue跨域单点登录实战:Token安全传递与postMessage桥接方案 前阵子公司内部要打通两套业务系统的登录状态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虽然可以通过设置Domaincompany.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?tokenxxxB 系统从 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可以设Domaincompany.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 3B 系统是 Vue 3 Vue Router 4两个项目都用hash模式路由。桥接页我部署在了https://sso.company.com/bridge.html这个域名专门用来放跨域通信页面。如果你没有单独的 sso 域名挂在 A 系统或 B 系统的域下也不是不行但建议单独弄一个逻辑上更干净。两个项目里都需要用到几个关键依赖Vue 项目本身是现成的请求库我用的是axiosToken 解析用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 跳转给 BA 系统的登录流程原本是用户输入账号密码后端返回 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 并向后端换取 TokenB 系统这边用户被跳到https://dashboard.company.com/?sso_ticketxxxx之后Vue 应用加载在路由守卫里统一处理这个 ticket。B 系统用的哈希路由URL 形式是https://dashboard.company.com/#/report?tokenxxx吗实际上这里我踩了个坑如果 B 系统是 hash 模式那么 URL 上的 query 参数最好是放在 hash 里面而不是放在域名后面。因为放在域名后面的参数在 Vue 应用刷新时可能会被后端/静态服务器处理掉hash 里的参数则完全由前端掌控。所以 A 系统跳转时目标 URL 应该拼成这样https://dashboard.company.com/#/sso/login?sso_ticketxxxx但上面第 3.2 节的跳转代码如果直接用window.location.href \${redirectUrl}?sso_ticket${res.ssoTicket}当redirectUrl是https://dashboard.company.com/#/sso/login时拼接后得到的是https://dashboard.company.com/#/sso/login?sso_ticketxxxx这是对的因为哈希后面直接拼接 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有自己的 localStorageA 系统登录后把 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 langzh-CN head meta charsetUTF-8 titleSSO 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 清理 queryVue Router history 模式刷新 404后端没配置 fallbackNginx 配置 try_files 指向 index.html5. 再说几个安全细节和后续扩展方向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。行业里常说的 CASCentral Authentication Service就是这个思路热词里也有“cas单点登录搭建”“ldap统一用户认证和单点登录”指的就是这一类。如果你们公司后面要接的系统越来越多建议尽早演进到这种模式认证中心负责登录页、Token 签发、会话管理所有业务系统都通过标准协议如 OAuth2 / OIDC接入。这样做的好处是各个业务系统之间完全解耦新系统上线时只需要对接认证中心不需要再重复实现一套跳转和换票逻辑。前端代码也可以抽成一个公共的sso-sdknpm 包业务系统一行代码接入后续维护成本会低很多。另外如果业务里要对接企业微信、钉钉这类第三方身份源认证中心模式也更方便只需要在认证中心做一次第三方授权登录的适配所有下游系统都能享受到免登能力。这个演进方向是这类功能落地一段时间后我感受最深的一点。
返回列表