做后台管理系统最烦的一件事就是:用户明明在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项目里拿到的sessionId,B项目的后端根本不认识,自然无法免登录。
而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 vue@latest # 或者用更传统的方式 npm init vue@latest cd your-project npm install npm install vue-router@4 npm install axios如果你手里有现成的Vue 3项目,直接npm install vue-router@4 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 在目标项目中接收并缓存Token
B项目作为目标项目,路由守卫启动前要先把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_token:access_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.com) | cookie设置domain为.fe.com | 刷新不丢,跳转无需带参数 | 依赖cookie,需要后端配合 |
| A、B完全跨域 | URL参数/一次性ticket | 通用性强,改造小 | 需要考虑泄露风险和参数清理 |
| A、B跨域且要求高安全 | 统一SSO实现redirect登录 | 安全、可审计 | 需要额外开发认证中心 |
| 页面嵌入集成 | iframe + postMessage | 无页面跳转,体验流畅 | 跨域策略复杂,不易调试 |
如果两个项目都部署在同一个主域名下面,比如oa.example.com和erp.example.com,那直接用cookie会省事很多。后端登录时设置Set-Cookie: token=xxx; 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,看到的是空白的默认管理员,还以为是系统漏洞,实际上就是没有展示当前登录人身份。把用户信息放到右上角,再配合登录日志,跨系统跳转才算是真正闭环了。