十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

前端登录鉴权方案深度实践:token存储、路由守卫与axios拦截器

前端登录鉴权方案深度实践:token存储、路由守卫与axios拦截器 2. 登录态承载方案token存哪里、状态放哪、刷新怎么办2.1 手搓之前先想清楚登录状态到底包含哪几样东西一个完整的登录鉴权不只是“登录成功存个token、路由守卫看一眼有没有token”这么简单。它实际上由四部分构成身份凭证token、用户信息profile、登录状态的有效期过期时间、以及状态变化后的全局响应退出、续期、并发登录处理。我见过太多手搓方案只实现了第一项把token往localStorage一扔然后路由守卫里判断“有token就放行”。结果就是用户token过期了页面还能点接口全报401然后一个弹窗接一个弹窗体验稀碎。你自己测的时候看到的永远是“刚刚登录”压根不会往过期方向想直到半夜线上用户来敲你。所以先从凭证方案说起。2.2 token存localStorage还是cookie这里面的门道比你想的多很多教程会说“token放localStorage简单方便”。从代码量看确实如此但2026年了安全这关躲不过去。存储位置优点缺点适合场景localStorageAPI简单、容量大、纯前端可控XSS下可被读取跨域带不走内部系统、低敏感场景、纯SPAsessionStorage关标签页即失效多标签页不同步临时会话场景cookieHttpOnlySecureSameSiteXSS读不到请求自动携带需要处理CSRF、Cookie大小限制、跨域配置复杂高安全要求、经典前后端同域部署实测下来我自己的原则是敏感度高的项目优先走HttpOnly Cookie由后端Set-Cookie写入前端几个接口配合做CSRF Token。中小型项目用localStorage也能跑但必须配合“内容安全策略”把XSS风险压住同时服务端给token设短一点的过期时间。很多人忽略的另一个点localStorage是不跨域隔离的“同源存储”如果你在同一个顶级域名下部署了多个业务系统另一个子系统的XSS也能顺手把token读走。跨域场景用localStorage还会出现“我登录了但请求头没法自动带”的问题只能靠前端拦截器手动加而手动加就难免漏。2.3 Pinia里的auth store2026年我不会再手搓全局变量以前大家习惯把用户信息放在Vuex里Vue 3时代用Pinia是主流。这里容易犯的错把用户信息一股脑塞进store刷新页面就没了。所以要用持久化。我不会推荐装一堆插件直接写一个store然后在state里显式做恢复代码量不大还好维护// stores/auth.js import { defineStore } from pinia import { loginApi, getProfileApi, logoutApi } from /api/auth export const useAuthStore defineStore(auth, { state: () ({ accessToken: localStorage.getItem(access_token) || , refreshToken: localStorage.getItem(refresh_token) || , profile: null }), getters: { isLoggedIn: (state) !!state.accessToken }, actions: { async login(credentials) { const { access_token, refresh_token } await loginApi(credentials) this.accessToken access_token this.refreshToken refresh_token localStorage.setItem(access_token, access_token) localStorage.setItem(refresh_token, refresh_token) await this.fetchProfile() }, async fetchProfile() { this.profile await getProfileApi() return this.profile }, async logout() { try { await logoutApi() } finally { this.clearAuth() } }, clearAuth() { this.accessToken this.refreshToken this.profile null localStorage.removeItem(access_token) localStorage.removeItem(refresh_token) } } })很多人问为什么用户信息也要存store不能直接从后端现取吗答案是可以现取但你得想清楚“每次刷新页面要不要重新拉一次用户信息”。如果路由守卫里每次进系统都调一次/profile接口理论上最保险代价是白屏时间变长、接口压力变大。我的做法是登录时拉一次刷新页面时用pinia的初始化逻辑拉一次后台遇401再拉一次。三处控制好体验和安全性都能兼顾。2.4 刷新Token到底走不走别让用户一天登录八次做了上面那套还有一个细节access token的过期时间设多少设太长泄露了风险大设太短用户老是被踢下线。现在的通用做法是双Tokenaccess_token有效期短15~30分钟服务端校验用refresh_token有效期长7~30天专门用来换新access_token这个方案最大的好处是用户只要在refresh_token有效期内访问系统就无感续期等到refresh_token也过期了才真正需要重新登录。而这部分逻辑放在axios拦截器里做最合适后面第4节重点讲。注意不是所有项目都需要双Token。纯内部系统、低并发办公后台单Token加短有效期完全够用面向C端的、需要长时间保持登录态的应用再上refresh机制。永远不要为了“高级”而把系统搞复杂。3. 路由守卫别只会判断token你的next()用对了吗3.1 白名单和首次加载两个最容易写错的边界路由守卫是第二道关卡。新手写出来的代码往往是这样的router.beforeEach((to, from, next) { const token localStorage.getItem(access_token) if (token) { next() } else { next(/login) } })这个写的其实不完整至少漏了三件事登录页和404这些页面应该走白名单不然用户没登录连登录页都进不去有token不代表用户信息已经加载过了刷新页面后profile可能是空的不做判断就可能出现“用户已经登录却跳回登录页”或“没登录却硬闯”的循环。我的做法是先在meta里打标记守卫统一处理// 白名单路由不校验登录态 const whiteList [/login, /register, /forgot] router.beforeEach(async (to, from, next) { const authStore useAuthStore() const hasToken !!authStore.accessToken if (hasToken) { if (to.path /login) { next(/) } else { if (!authStore.profile) { try { await authStore.fetchProfile() next({ ...to, replace: true }) } catch (error) { authStore.clearAuth() next(/login?redirect${encodeURIComponent(to.fullPath)}) } } else { next() } } } else { if (whiteList.includes(to.path)) { next() } else { next(/login?redirect${encodeURIComponent(to.fullPath)}) } } })这里有个容易被忽略的经验点fetchProfile失败时不要原地不动要“清除登录态并跳登录页”否则守卫会进死循环。同时跳转时带上redirect参数登录成功后可以回到用户原本想去的页面这个细节在用户体验上值不少钱。3.2 动态路由不注册菜单写了也白写很多系统登录后要根据角色渲染不同菜单和路由。最常见的坑开发环境一切正常一到生产环境用户刷新某个页面直接404。原因基本都一样动态路由是用router.addRoute后加的但刷新页面后路由被重置了而你又没有在“刷新后重新注册路由”的逻辑里兜住。一旦某个路由是动态注册的刷新时它压根不存在路由表里没这条自然走404。解决思路是把动态路由的注册做成一个幂等操作每次刷新都先重置再按权限注册。Vue Router 4里可以用router.removeRoute也可以用router.addRoute配合hasRoute判断。我更推荐维护一份constantRoutes和dynamicRoutes的模块清单启动时明确区分// router/index.js 里做一次初始化 const constantRoutes [...] let dynamicRoutesAdded false export function resetRouter() { dynamicRoutes.forEach(route { if (router.hasRoute(route.name)) { router.removeRoute(route.name) } }) } export async function setupDynamicRoutes(permissionCodes) { resetRouter() const routes filterRoutesByPermission(asyncRoutes, permissionCodes) routes.forEach(route router.addRoute(route)) dynamicRoutesAdded true }这个“先重置后注册”的动作必须放在每次刷新后重新获取用户信息之后执行。顺序错了或者漏了resetRouter就会出现“用户权限变了但路由还是老样子”的诡异问题。3.3 404页面兜底注册顺序决定一切动态路由里最后一个坑是404匹配。Vue Router的路由匹配按注册顺序来如果/:pathMatch(.*)*的通配路由在动态路由之前注册那动态路由永远匹配不到自己全被通配吞掉。正确做法是把通配404路由放到最后动态注册或者拆成两套基础路由里不放通配等动态路由全注册完了再把/:pathMatch(.*)*加进去。这个顺序问题我在不止一个项目里遇到过排查过程很费神写下来就是希望你别在同样地方耗一晚上。4. 401自动续期与并发请求排队axios拦截器的正确打开方式4.1 请求拦截器token别加载localStorage上瘾axios请求拦截器的主要职责是给请求头挂Authorization。这个东西本身很简单但有没有考虑过“token从哪来”我见过有的项目每次发请求都localStorage.getItem其实完全没必要直接从Pinia store里取响应更快也方便统一管理。简单示例如下service.interceptors.request.use(config { const authStore useAuthStore() if (authStore.accessToken) { config.headers.Authorization Bearer ${authStore.accessToken} } return config })4.2 响应拦截器401其实有两种别一棍子打死401响应要分类处理access_token过期refresh_token还有效应该静默续期后重放原请求refresh_token也过期用户彻底掉线需要清状态、跳登录、带redirect如果代码里不分这两类直接弹“登录过期请重新登录”那两个小时前刚登录过的用户就会被莫名其妙踢下线而且是在我前面第2节设了“短有效期access token”的前提下。所以真正到生产环境必须做自动续期。4.3 并发刷新队列这个细节不处理好接口必炸自动续期的核心逻辑不复杂拿到401后调一次refresh接口拿到新token把之前失败的请求重新发一遍。但有一个细节特别容易翻车页面同时发出10个请求后端到期后10个请求全部返回401这时候要是每个请求都去调一次refresh接口后端就被打爆了而且多个刷新请求返回的token还会互相覆盖。解决方案是维护一个“正在刷新”的标识刷新过程中其他401请求先挂起等刷新完统一重放let isRefreshing false let pendingQueue [] service.interceptors.response.use( response { // 业务层约定直接返回response.data也行看个人习惯 return response.data }, async error { const { response, config } error if (!response) { return Promise.reject(error) // 网络异常不处理 } const authStore useAuthStore() if (response.status 401 !config._retry) { if (!authStore.refreshToken) { authStore.clearAuth() router.push(/login?redirect${encodeURIComponent(router.currentRoute.value.fullPath)}) return Promise.reject(error) } if (isRefreshing) { return new Promise(resolve { pendingQueue.push(token { config.headers.Authorization Bearer ${token} config._retry true resolve(service(config)) }) }) } config._retry true isRefreshing true try { const { access_token } await refreshTokenApi(authStore.refreshToken) authStore.accessToken access_token localStorage.setItem(access_token, access_token) pendingQueue.forEach(cb cb(access_token)) pendingQueue [] config.headers.Authorization Bearer ${access_token} return service(config) } catch (refreshError) { pendingQueue [] authStore.clearAuth() router.push(/login?redirect${encodeURIComponent(router.currentRoute.value.fullPath)}) return Promise.reject(refreshError) } finally { isRefreshing false } } return Promise.reject(error) } )这里有两个地方容易出错。一个是config._retry标志必须加不然刷新完重放遇到401又会进拦截器形成无限循环。另一个是pendingQueue里的回调要“消费掉”刷新失败或者刷新成功都要把队列清空不然会留下悬空Promise页面看起来像卡死了一样。经验补充refresh接口本身如果返回401说明refresh_token也失效了这时千万别再走刷新逻辑直接清状态跳登录。所以refreshTokenApi的调用本身最好用一个新的axios实例不要用这个带拦截器的实例防止套娃。5. 按钮级权限v-permission指令和composable权限判断登录鉴权做到这一步很多人会觉得“差不多得了”。但真正的业务系统光能进页面远远不够。一个页面里按角色显隐按钮是几乎所有管理后台都会碰到的需求。5.1 三种常见的权限判断写法我为什么不推荐前两种第一种在组件里写v-ifrole admin。这个改起来麻烦角色一多全是硬编码。第二种把权限码存在store里组件里调authStore.hasPermission(user:create)比第一种好能用了。第三种封装成自定义指令和composable模板和脚本里都用同一种语义可读性最好。我实际项目里用的是第三种代码可以保持统一// directives/permission.js import { useAuthStore } from /stores/auth export const permission { mounted(el, binding) { const authStore useAuthStore() const { value } binding if (value !authStore.hasPermission(value)) { el.parentNode?.removeChild(el) } } }// composables/usePermission.js import { useAuthStore } from /stores/auth export function usePermission() { const authStore useAuthStore() return { hasPermission: authStore.hasPermission } }模板里就变成了button v-permissionuser:create新增用户/button脚本里做成动态判断时const { hasPermission } usePermission() const canEdit computed(() hasPermission(user:update))5.2 权限码和接口权限不能互相替代要把权限设计想清楚前端做按钮显隐只是“体验优化”不是安全边界。危险操作后端必须重新校验权限否则绕过前端直接调接口什么权限码都是摆设。另外一点权限码的定义要跟后端统一。我见过很多项目前端叫user:add后端叫user:create两边各写各的后期维护全是坑。比较接地气的做法是权限码清单由后端接口下发或者前后端在同一份接口文档里维护同一个枚举谁改谁发起联调确认。5.3 自定义指令的“移除节点”并不是唯一方案el.parentNode?.removeChild(el)这种方式干脆利落但要注意一个副作用如果VNode还在某些情况下组件生命周期会异常。还有一种方案是用v-if包一层或者用v-permission直接返回一个包装组件的渲染函数但那又走远了。基于我自己的经验Vue 3里指令配合具体DOM节点是最通用的能解决90%的按钮显隐需求。真遇到弹窗里的按钮、表格插槽里的操作列按钮建议在数据预处理阶段就把权限过滤做掉不要让权限逻辑散落在模板各处。6. 别再造轮子了工程化落地时我保留的底线与哪些习惯6.1 先看看成熟方案再决定要不要自己写手搓的一个隐藏成本是“代码看起来能跑边界条件全靠加班补”。所以2026年了做登录鉴权第一件事不是打开编辑器而是先看看社区里现成的方案。前端有几个方向可以参考直接使用开源后台模板比如若依Vue版、vue-element-admin、vue-vben-admin它们已经把登录、路由守卫、权限控制、按钮显隐这套基础设施搭好了。如果你用的是TypeScript还可以看看一些组合式API封装VueUse的createSharedComposable等工具能让store和hooks复用更顺手。我的个人建议如果你是做企业级后台直接用若依这种“开箱全家桶”并仔细读懂它的实现比从零手搓可靠得多。如果你是非后台业务比如内容站、工具站登录鉴权相对简单那自己实现“登录守卫拦截器”三步完全没问题但千万不要在简单的场景里硬套复杂权限模型。6.2 不要忽略的几个安全底线手搓登录鉴权最容易忽略的三个安全问题XSS攻击localStorage里的token只要被一段恶意脚本读到账号就没了。所以除了存储方案的选择还要注意Vue的v-html别滥用、用户输入要好好过滤、CSP响应头要配上。CSRF攻击如果用了Cookie方案CSRF防护必须做常见的做法是SameSite属性加token校验。如果用了Authorization头的方式CSRF风险天然低很多。日志和审计登录、登出、权限不足这些事件应该打日志方便出事之后定位。很多人觉得这是后端的事但前端把异常信息和接口状态记录清楚比如统一上报到监控平台线上排查会快很多。6.3 我实际踩过坑之后沉淀下来的习惯最后分享几个自己反复用到的小习惯。第一路由守卫、axios拦截器、pinia store这三块代码之间不要相互import混用尽量单向依赖。一般是“store → 守卫/拦截器”不要让store里去调用router跳转否则改起来牵一发动全身。确实需要跳转的话用window.location或单独封一个跳转工具函数而不是在store里import router。第二给token设置统一过期时间时要在后端做一次时钟校验不要完全相信前端解析JWT的exp字段。因为客户端时间可以被用户改也可以因为时区问题出错这也是半夜被叫起来处理过的问题。第三登录流程要能在本地开发时“绕过”。不是每个人都要在本地起后端尤其是前端同事只改样式的时候长长的登录流程会拖慢效率。我们项目里做了一个VITE_USE_MOCKtrue的环境变量开启后走mock登录不用真实后端也能完整调试整套鉴权链路。第四别把refresh_token的过期时间设太长。有些项目为了偷懒设置了30天不失效安全风险还是不小的。密码重置、账号冻结、异地登录检测这些能力用户感受不到但你被拖出来“加班复盘”的时候就知道它们值多少了。登录鉴权这件事说难不难说简单也真不简单。所谓“半夜被自己代码吓醒”本质上是平时写的时候少想了几个边界条件。把上面这几块的逻辑理清楚平时注意代码组织别再堆一堆工具函数其实手搓也完全可行。怕就怕在“以为只有几行代码的事”最后被生产环境吊打。希望这篇分享能让你少走几步弯路。
返回列表