
做后台管理系统最烦的一件事就是用户明明在A系统里已经登录过了点个链接跳到同一套技术体系下的B系统结果又要重新输一遍账号密码。尤其是公司内部多个Vue项目并行今天要在订单系统看单明天去商品系统改价来回切换全靠重复登录体验差不说开发还得各自维护一套账号体系。我之前处理过多次这种“跨项目免登录跳转”的需求最常用也最稳的方案就是靠token 路由守卫在Vue Router的导航守卫里拦截跳转识别来源系统带过来的token校验通过就自动放行完成免登录。这篇文章我会把整套实战思路拆开讲清楚从最朴素的URL传token到目标项目如何安全接收与存储再到路由守卫里的校验逻辑、token续签、跨域部署常见的坑最后给出可直接复制的代码。适合手里有几个Vue中后台系统、正在被“重复登录”折磨的团队也适合想系统理解Vue路由守卫和token校验机制的开发者。1. 场景梳理与整体方案设计1.1 需求拆解跨项目免登录跳转到底在解决什么很多人一开始会把“免登录跳转”想成一个前端跳转问题其实它本质上是一个登录凭证信任问题。A项目跳转到B项目时B项目怎么知道“你”就是A项目里那个已登录的合法用户如果B系统不信任A系统给的任何凭证那无论如何都免不了登录。所以整个方案的逻辑闭环有三步A项目确认用户已登录并且有一个可以代表用户身份的凭证token。A项目把这个凭证通过某种方式传递给B项目。B项目在路由跳转前用路由守卫拦截拿到凭证后调接口校验校验通过就放行校验失败就清除本地缓存并跳转登录页。这里面“某种方式”是整个方案的核心变量。同域名下的两个项目可以用localStorage或cookie直接共享不同域名下的两个项目最常见的低成本方案就是URL参数传递token。而“路由守卫”解决的是第3步里的“什么时候去校验、怎么处理校验结果”。把这两件事拆开之后问题就清晰了先解决token怎么传过去再解决路由守卫里怎么处理token。1.2 为什么优先选“token 路由守卫”而不是session/cookie传统session方案里登录态存在服务端浏览器只保存一个sessionId。这种方式在同一个后端服务下很好用但公司内部多个Vue项目往往是独立部署、独立后端甚至可能不同团队维护。你在A项目里拿到的sessionIdB项目的后端根本不认识自然无法免登录。而token方案尤其是JWT是无状态的token本身就包含用户标识、签发时间、过期时间等信息后端拿到后只需要验签和查过期时间不需要像session那样做集中式存储。前端统一把token存在localStorage或cookie里路由守卫每次跳转时检查一下不存在就拦下来存在就试着调用户信息接口两者配合非常契合Vue单页应用的组织方式。当然token方案不是银弹它也有续签、泄露风险等一堆问题。但相比session方案的多系统会话同步实现跨项目免登录跳转的工作量会小很多后期扩展统一登录中心也方便。1.3 整体流程设计我从实战中梳理了一套比较标准的流程后面所有代码都是按这个流程来的用户在A项目登录成功后端返回access_token和refresh_token前端存储到本地。用户点击A项目里的“进入B系统”入口前端生成一个带签名、带时间戳、带用户标识的跳转地址目标地址是B系统的某个页面URL上带token参数。B项目启动后在路由全局前置守卫beforeEach里读取目标地址里的token。如果URL里有token先存下来再通过history.replaceState把URL清洗干净防止刷新或分享时token一直暴露。B项目接着检查本地有没有token。没有就跳B项目登录页有就调后端“当前用户信息”接口判断是否有效。接口成功说明token有效放行。接口返回401说明token过期先尝试用refresh_token续签续签成功就更新token并放行续签失败就清空本地登录态跳转登录页并带上A项目的回跳地址用户只需在A项目再登录一次然后再次跳回来。2. 前置准备搭建Vue 3 Vue Router 4环境与基础封装2.1 初始化项目与依赖安装现在做新项目基本都以Vue 3为主Vue Router也升级到了4.x版本API从new Router()改成了createRouter但路由守卫的核心思路和Vue 2时期一致。先准备一个干净的项目npm create vuelatest # 或者用更传统的方式 npm init vuelatest cd your-project npm install npm install vue-router4 npm install axios如果你手里有现成的Vue 3项目直接npm install vue-router4 axios就行。Vue Router 4要注意一点的Vue 3已经不支持Vue Router 3别装错了版本装完后可以在package.json里确认一下依赖版本vue-router应该显示^4.x.x。项目结构上我习惯保留一个src/utils/auth.js专门管token读写一个src/utils/request.js封装axios一个src/router/index.js放路由和守卫。这样跨项目迁移代码时只需要把这几个文件复制过去改一下接口地址就行。2.2 Token存储与请求拦截封装auth.js主要做三件事读取、写入、清除。跨项目免登录跳转时B项目从URL把token拿出来第一件事就是写进这个模块// src/utils/auth.js const ACCESS_TOKEN_KEY access_token const REFRESH_TOKEN_KEY refresh_token export function getAccessToken() { return localStorage.getItem(ACCESS_TOKEN_KEY) } export function setTokens(accessToken, refreshToken) { localStorage.setItem(ACCESS_TOKEN_KEY, accessToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken) } export function clearTokens() { localStorage.removeItem(ACCESS_TOKEN_KEY) localStorage.removeItem(REFRESH_TOKEN_KEY) }有人喜欢把token放sessionStorage我建议跨项目跳转场景下优先放localStorage。原因很简单如果A项目让token在URL里带过来B项目刚把token存进sessionStorage用户不小心关了个标签页整个免登录上下文就没了又得重新从A项目跳一次。localStorage能扛到浏览器关闭体验更稳。axios拦截器是token方案里绝对绕不开的一环。request.js里统一做请求头注入以及后面要讲的401统一续签// src/utils/request.js import axios from axios import { getAccessToken } from ./auth const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) request.interceptors.request.use((config) { const token getAccessToken() if (token) { config.headers.Authorization Bearer ${token} } return config }) export default request注意这一步看起来很简单但“所有请求自动带token”是后续路由守卫能安心调用户信息接口的前提。如果你在路由守卫里单独调用用户接口时还要手动拼token那这个封装就不够彻底。2.3 路由表设计路由守卫需要知道哪些页面需要登录、哪些页面可以放行。我习惯在路由的meta上挂一个requiredAuth字段比如登录页和404页不用校验其它业务页面全部走校验// src/router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /login, name: Login, component: () import(/views/login/index.vue), meta: { requiredAuth: false } }, { path: /dashboard, name: Dashboard, component: () import(/views/dashboard/index.vue), meta: { requiredAuth: true } } ] const router createRouter({ history: createWebHistory(), routes }) export default routerrequiredAuth这种显式的标记比“只要不是login都需要登录”要好维护。后面可能会加一些回调页、公开活动页统一在路由表里声明守卫里的白名单逻辑就很简单了。3. 路由守卫与Token校验的核心实现3.1 在来源项目中安全构造跳转链接假设A项目是来源项目用户已经登录token也在本地现在要跳转到B系统。最直观的做法是const token getAccessToken() const redirectUrl encodeURIComponent(window.location.href) window.location.href https://b.example.com/dashboard?token${token}redirect${redirectUrl}它能跑通但也真的很危险token直接暴露在URL里用户复制链接、浏览器历史记录、nginx access log里都可能留下完整token别人拿到就能冒充用户。所以我更建议在URL里传一个短时效的“一次性票据”而不是长期token。这个票据由后端或前端生成带签名、带过期时间、绑定用户B项目拿到后调接口换正式token。这样即使链接泄露票据几分钟后就失效风险可控。前端代码可以封装一个generateJumpUrl方法// src/utils/sso.js import { getAccessToken } from ./auth import { request } from ./request // 建议由后端生成一次性ticket前端只负责拼接 export async function buildSsoUrl(targetBaseUrl, targetPath /dashboard) { const { data } await request.post(/sso/ticket, { // 传给后端的参数目标系统标识、当前用户等 targetSystem: b-system }) const query new URLSearchParams({ ticket: data.ticket, ts: Date.now(), // 时间戳后端可以做有效期校验 source: system-a }) return ${targetBaseUrl}${targetPath}?${query.toString()} }调用时直接const url await buildSsoUrl(https://b.example.com, /dashboard) window.location.href url如果你们的后端暂时不愿意做ticket接口临时用access_token顶一下也不是不行但务必做到以下几点只传短token、不超过10分钟有效期、跳转后的目标页面必须立刻清洗URL参数、只允许跳向配置好的白名单域名。长期裸传token的结局我见过太多次了千万别图省事。3.2 在目标项目中接收并缓存TokenB项目作为目标项目路由守卫启动前要先把URL里的ticket或token“接住”。这里有一个细节容易被忽略不要在组件mounted里才去读URL参数因为路由守卫执行得比组件渲染更早如果你在守卫里判断“本地无token就跳转登录页”此时URL里的ticket还没被读取就等于把刚送来的免登录凭证给扔了。正确做法是在beforeEach守卫一开始就处理URL参数。// src/router/index.js import { getAccessToken, setTokens, clearTokens } from /utils/auth import request from /utils/request function handleSsoParams(to) { const ticket to.query.ticket const ts to.query.ts const source to.query.source if (!ticket) return false // 建议调后端接口用ticket换取正式token // 后端会校验ts和ticket的有效期、一次性、来源系统 return request.post(/sso/verify, { ticket, ts, source }) .then((res) { const { accessToken, refreshToken } res.data setTokens(accessToken, refreshToken) return true }) .catch(() { clearTokens() return false }) }拿到并缓存token后要立刻把URL里的ticket、ts、source这些参数清掉。不清理的话用户刷新一次页面token参数就重新出现一次更尴尬的是用户复制当前URL发给别人别人打开后也会触发一次ticket换token逻辑而一次性ticket已经被消费过了反而报错。清洗URL用history.replaceState就好function cleanSsoParams() { const url new URL(window.location.href) url.searchParams.delete(ticket) url.searchParams.delete(ts) url.searchParams.delete(source) window.history.replaceState({}, document.title, url.toString()) }replaceState不会刷新页面也不需要用户感知体验比较自然。3.3 全局前置守卫里完成免登录校验接收和缓存token只是前提真正把“免登录跳转”落地的是router.beforeEach。这个守卫会在每次路由跳转前执行我们可以在这里实现完整流程// src/router/index.js router.beforeEach(async (to, from, next) { // 1. 先从URL中尝试换取token const handled await handleSsoParams(to) if (handled) { cleanSsoParams() } // 2. 判断当前路由是否需要登录 const requiredAuth to.meta.requiredAuth ! false // 3. 获取本地token const token getAccessToken() if (!requiredAuth) { // 登录页、公开页不需要token直接放行 // 但登录页如果已有token通常跳回首页 if (to.path /login token) { next(/dashboard) return } next() return } // 4. 没有token去登录页 if (!token) { next({ path: /login, query: { redirect: to.fullPath } }) return } // 5. 有token但还没拉取用户信息就拉一次 // 这里用pinia或一个简单的全局标识避免每次路由都重复请求 if (!useUserStore().userId) { try { await useUserStore().fetchUserInfo() next() } catch (error) { // 6. token无效先尝试刷新刷新失败再去登录页 const refreshed await refreshTokenIfNeeded() if (refreshed) { next() } else { clearTokens() next({ path: /login, query: { redirect: to.fullPath } }) } } } else { next() } })这里我遇到过一个高频问题很多人把“拉取用户信息”写在每次路由跳转的地方结果一个详情页里连续跳转两三次路由就会并发请求两三次用户信息接口。最好把用户信息放到全局状态管理里只请求一次路由守卫里判断一下状态再决定要不要重新请求。3.4 Token过期后的刷新与路由控制token一定会过期过期后如果直接把人踢回登录页体验会很差。现在通行的做法是引入refresh_tokenaccess_token有效期短比如2小时refresh_token有效期长比如7天。前端在axios响应拦截器里收到401后静默调用刷新接口拿到新access_token后重放原请求。路由守卫里也一样但要注意避免递归刷新。let isRefreshing false let pendingQueue [] async function refreshTokenIfNeeded() { const refreshToken getRefreshToken() if (!refreshToken) return false if (isRefreshing) { return new Promise((resolve, reject) { pendingQueue.push({ resolve, reject }) }) } isRefreshing true try { const { data } await request.post(/auth/refresh, { refreshToken }) setTokens(data.accessToken, data.refreshToken) isRefreshing false pendingQueue.forEach((p) p.resolve(true)) pendingQueue [] return true } catch (error) { isRefreshing false pendingQueue.forEach((p) p.reject(error)) pendingQueue [] return false } }路由守卫里调用刷新接口时就可能会出现一个冷启动问题刷新接口走的是同一个request实例如果刷新接口本身也返回401你就会陷入“请求-刷新-再请求-再刷新”的循环。所以request拦截器里要放行refresh接口本身不能给这个接口也套一层“401后去刷新”的逻辑否则会形成死循环。我在项目里通常用一个isRefreshRequest标识来标记这类接口拦截器判断到就跳过处理。4. 跨域场景、Token安全与部署避坑4.1 不同域名下的Token传递方案对比跨项目免登录跳转最大变数不是前端代码而是“A项目和B项目的域名关系”。我把常见场景总结成一张表场景可用方案优点缺点A、B同域名同源localStorage直接共享实现简单无需跳转传递只适合同源项目A、B同主域名如a.fe.com / b.fe.comcookie设置domain为.fe.com刷新不丢跳转无需带参数依赖cookie需要后端配合A、B完全跨域URL参数/一次性ticket通用性强改造小需要考虑泄露风险和参数清理A、B跨域且要求高安全统一SSO实现redirect登录安全、可审计需要额外开发认证中心页面嵌入集成iframe postMessage无页面跳转体验流畅跨域策略复杂不易调试如果两个项目都部署在同一个主域名下面比如oa.example.com和erp.example.com那直接用cookie会省事很多。后端登录时设置Set-Cookie: tokenxxx; Domain.example.com; Path/; HttpOnly两个项目后端都能解析同一个cookie前端基本不用做什么。但要注意如果后端是不同团队维护必须确认两边都能读取并验签同一份cookie。否则还是走URL参数方案更可控。4.2 安全加固避免Token在跨项目跳转时裸奔URL传递token最怕两件事链接被转发、日志被扒。在安全上我建议至少做以下四件事不传长期access_token改为一次性ticket有效期5分钟以内只能换一次。ticket必须和用户身份绑定后端兑换时校验当前请求是否来自合法来源IP或Referer。跳转目标加入“允许列表”只允许跳向配置过的域名防止拼接恶意地址。跳转过去后立刻用history.replaceState清理URL参数同时配合Referrer Policy设置no-referrer避免跨域跳转时浏览器把带参数的完整URL带给目标站点。即使你不是在搞SSO而只是两个内部Vue项目之间互跳也建议把上面这四条作为最低底线。内部系统不等于百分百安全我在多个公司都见过有人用公司内部系统分享链接链接里带着登录token点开就能以对方身份进系统。4.3 打包部署后History模式刷新404路由守卫做得再好如果项目部署在nginx子路径下或者刷新后直接404免登录跳转也会变成“跳过来一片白”。Vue Router 4默认createWebHistory基于History API需要服务器把所有路由回退到index.html。nginx配置里加一段location / { try_files $uri $uri/ /index.html; }如果是二级目录部署比如https://example.com/b/还需要在Vue Router里传createWebHistory(import.meta.env.BASE_URL)同时打包配置base: /b/。这个坑很容易被忽略因为本地开发时一切正常跳到B项目后手动刷新一次就404然后就会误以为是路由守卫写错了。5. 常见问题与排查技巧实录5.1 路由守卫执行后白屏或死循环这个现象最典型的是beforeEach里没写next()或者错误地拦截了所有路由。还有更隐蔽的登录页也设置了requiredAuth: true结果登录页本身也要登录守卫就反复重定向。排查时先看控制台有没有Maximum call stack size exceeded或者反复跳转的日志。守卫逻辑建议顺序固定先处理公开页面再处理登录页最后处理需要token的页面不要把所有判断混在一堆if里。5.2 Token续签遇到“refresh_token为空”很多团队第一次做续签时会在axios拦截器里取错字段报错类似invalid refresh_token: empty string。最常见原因是登录接口返回的生命周期Promise没有正确存储导致后续刷新时读到空字符串还有一种是服务端在刷新成功后返回了新的refresh_token但前端仍使用旧值一旦旧refresh_token被服务端作废就会连续失败。我调试这类问题时的步骤是先确认接口返回字段名别想当然用refreshToken看后端到底返回的是refresh_token还是refreshToken。在每个步骤打日志确认写入localStorage的refresh_token非空。刷新成功后再看下一次请求的Authorization头是不是新token。如果刷新接口返回了新的refresh_token一定要同步覆盖本地存储不能只更新access_token。5.3 跳转时后端返回403/400错误当你从A项目跳到B项目B项目拿着ticket或code去调用后端接口时如果返回类似token exchange failed的报错不要慌绝大多数是配置问题而不是代码问题。我会按以下顺序检查client_id、client_secret是否正确很多系统从这里开始就是错的。授权类型grant_type是否匹配是authorization_code还是refresh_token还是client_credentials别混。ticket/code是否已经使用过一次性票据被重复兑换就是会报错。服务器时间是否同步JWT或ticket签名校验里经常带时间窗口服务器之间时钟不同步会直接拒绝。回调地址redirect_uri是否和后端配置的一致多一个斜杠、大小写不同都会失败。这类错误通常和前端路由守卫无关别在守卫里耗太久直接用Postman调一次后端接口能把问题范围缩小很多。5.4 URL参数里的Token丢失或没有写入本地有过一次真实经历A项目跳B项目后B项目路由守卫拿to.query.ticket时干干净净什么也没有。查了半天发现A项目跳转前在buildSsoUrl里用了URLSearchParams其中一个参数值是中文但没有encodeURIComponent整个URL被浏览器自动处理最终把ticket挤掉了。从那以后我养成了习惯所有URL参数都必须显式编码宁可多写几个encodeURIComponent也不要相信浏览器自动处理。还有一种情况是B项目在main.js里先执行了router.beforeEach然后又有插件或代码调用了next()之前就对to.query做了修改。如果用了vue-router的导航守卫和类似vue-meta这类依赖路由参数的插件也可能提前消费参数。建议在守卫代码第一行就打印to.fullPath确认参数在进入守卫时是存在的再往下走。5.5 跨项目跳转后业务接口全部401如果你确认token已经写入本地但业务接口全部401那大概率是token的作用域和接口后端不匹配。比如A系统签发的token里的audience指向的是A系统网关B系统后端不认或者B项目网关要求每个请求头必须带X-Client-Id但你只带了Authorization。这类问题不是路由守卫能解决的需要核对两个系统的网关策略看看A系统token的签发端和B系统token的验证端是否是同一套。如果不是就得走一次性ticket换新token的流程而不是直接透传。6. 从“免登录跳转”延伸到统一身份认证6.1 不要满足于“两个系统能跳”上手直接做“URL传token”是最快的但它只能解决“点链接跳转”这一件事。公司系统一多每个系统都维护自己的token和用户权限后期改造成本会非常高。我个人的建议是如果你预计会有三个以上系统做免登录就值得提前引入一个轻量认证中心。前端仍然用路由守卫但校验逻辑会变成没有token时跳认证中心登录认证中心登录成功后带code回来前端再拿着code换取本系统token。这套流程其实和文章前面讲的一次性ticket非常像只是把“来源系统换token”变成了“认证中心统一发码”。路由守卫的骨架不变变的只是token来源和校验接口。所以你现在照着前面代码做出来的东西以后升级到SSO时并不会白做守卫、token封装、续签逻辑都能复用。6.2 微前端场景下的路由守卫与Token共享如果A项目和B项目不只是“页面跳转”而是用微前端把两个子系统嵌到同一个壳里那路由守卫的落点会复杂一些因为子应用可能同时存在hash路由和history路由。这时就要用基座应用统一管理token然后通过props或者自定义事件注入子应用。子应用里的beforeEach拿不到也没关系关键是基座在下载子应用前就把登录态校验好。这种方案下最忌讳的是子应用自己再去写一套单独的token校验否则会出现“基座已登录子应用还调登录接口”的混乱。6.3 后续优化方向免登录跳转一旦跑通后面值得优化的点还有很多用户信息接口可以做缓存比如10分钟内不重复拉取token续签接口要做并发合并防止多个请求同时触发刷新路由守卫里的异步操作要做好loading状态避免跳转白屏期间用户狂点按钮。另外建议给所有请求加统一错误提示把401和403分开处理401走静默续签403提示“无权限”这样更符合实际使用预期。我自己后来在做新的后台系统时已经把token存储、刷新、路由守卫抽成了一个公共包每个新Vue项目只需要初始化时调用一下传入登录接口地址、用户信息接口地址和系统标识就能天然获得免登录跳转能力。这个方法也推荐给准备在团队里推广这套机制的读者把复用的工作做在前面后面每个项目都能少踩一遍同样的坑。最后分享一个实战中的小细节免登录跳转成功之后B项目里一定要给用户一个“当前是xxx用户”的展示而不是默默放行。很多项目做完免登录后用户从A跳到B看到的是空白的默认管理员还以为是系统漏洞实际上就是没有展示当前登录人身份。把用户信息放到右上角再配合登录日志跨系统跳转才算是真正闭环了。