ARTICLE DETAIL

资讯详情

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

Vue刷新丢登录状态?一文详解Token原理与7天免密登录实现

Vue刷新丢登录状态?一文详解Token原理与7天免密登录实现 你有没有遇到过这种情况好不容易把Vue项目开发完登录、跳转、拉取用户信息、权限控制全都跑通了测试小姐姐也在验收单上签了字。结果你在浏览器里随手按了一下F5页面转了一圈又回到登录页。当场血压拉满。这种“刷新丢登录状态”的问题在Vue项目里几乎是新人必踩的坑而且踩法五花八门——有人是刷新后白屏有人是刷新后接口全部401有人是明明token还在localStorage里路由却偏说没登录。我甚至见过一个项目把token存进了sessionStorage然后跟产品吵了一下午“为什么用户关掉浏览器再打开就要重新登录”。这篇文章就围绕三个问题展开刷新为什么会丢登录状态、Token到底是什么、以及网站常见的“7天免密登录”核心原理是怎么做的。我会把排查思路、代码实现、参数怎么定、坑在哪里全部写出来拿捏不准的可以直接抄作业。1. 从一次“刷新丢登录”的尴尬现场说起1.1 先给问题画个像“刷新丢登录”这个描述其实很笼统不同项目表现不一样但根因往往相似。先梳理一下最典型的几个症状刷新后跳回登录页而且localStorage里确实没有token了刷新后路由没跳但所有请求都返回401刷新后接口能通但页面上的用户信息全空了像是“半个登录状态”只在一部分浏览器或者隐身模式下复现这些问题经常混在一起出现又会让开发者误以为是“Vue的问题”“路由的问题”“axios的问题”但实际上绝大多数跟框架本身没有半毛钱关系。Vue也好、React也好刷新页面之后浏览器都会重新加载整个文档JavaScript运行时的内存状态也就是你存在变量里的那些数据全部清空应用要重新初始化一遍。这意味着只要token没有落在持久化存储里或者应用初始化时没有主动去恢复它登录状态就是必丢的。1.2 排查前必须搞清楚的链路想解决这个问题得先画出一条“登录状态生命周期”链路。我做过的几个项目里凡是能三分钟定位到问题的人脑子里的链路基本一致用户在登录页输入账号密码提交给后端后端校验通过返回一个token以及用户信息前端拿到token存到某个地方localStorage / sessionStorage / cookie / vuex之后每次请求前端把token塞进请求头或Cookie里刷新页面应用重新启动路由守卫/初始化逻辑去读存储里的token如果读到了放行并拉取用户信息如果没读到踢回登录页“刷新丢登录状态”这件事本质上就是第5步出了问题——要么没读到要么读到了但不认为是有效的。排查的时候不要一上来就翻路由守卫而是先从“刷新后存储里到底还有没有token”这一步开始。我见过有人排查了一整天最后发现是登录时压根没做存储token只挂在内存变量上刷新必丢——这种低级问题只要按链路捋一遍五分钟就能定位。提示把“登录状态链路”写下来打印出来贴工位旁边比记一堆框架API有用得多。排查问题先分环节再定位环节效率会高非常多。2. Token到底是个什么东西2.1 从“会话”到“凭证”的进化史很多同学在做登录功能时是把“token”当成一个黑盒来用的——后端返回什么就存什么要什么就给什么。但你想把7天免密登录做好、想排查清楚刷新丢登录的根因就必须理解Token在设计上到底解决了什么问题。咱们先用大白话捋一下历史。最早的Web登录靠的是Cookie Session用户登录后服务器在内存或数据库里创建一个会话记录把会话ID通过Set-Cookie塞给浏览器浏览器以后每次请求自动带上这个Cookie服务器比对一下会话ID就知道你是谁。这方案到今天也没过时但有个痛点——如果后端是多实例部署比如负载均衡挂了三台服务器会话存在了第一台机器上第二台机器不认识用户请求就会被随机踢出去。后来有了粘性会话、Session共享、Redis集中存储但代价都是引入额外的设施和复杂度。Token的思路换了赛道服务器不再存“会话状态”而是直接把用户身份和权限信息签名打包成一个字符串交给客户端客户端请求时原样带回来服务器验签通过就认账。这样一来服务端变成无状态的哪台机器都能验也就天然适合分布式部署。2.2 JWT的结构和有效期设计目前最主流的Token形态是JWTJSON Web Token它的结构是“三段式”头部Header、载荷Payload、签名Signature用点号拼接。头部声明算法类型载荷放用户ID、过期时间等业务数据签名是用密钥对前两段内容进行的哈希加签。// 一个典型JWT长这样 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 .eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c其中载荷里有三个时间字段需要你格外关注iat签发时间、exp过期时间、nbf生效时间不常用。为什么要关注因为很多“刷新后突然没登录”的问题本质就是exp算得太短你登录完随手刷新一下token已经过期了。后端如果设了比如5分钟有效期那就是有意为之需要配合刷新机制但如果后端稀里糊涂把过期时间设成几秒钟那前端再怎么折腾都是白搭。JWT的优势是自包含缺点是“无法主动作废”——只要token没过期就算用户改了密码旧token在服务端验签时依然是合法的。所以纯JWT方案里服务端一般还要配合黑名单/白名单或者引入我们后面要讲的refresh token机制来缓解这个问题。2.3 为什么不能把Token塞进URL这一小节属于安全扫盲但跟登录状态息息相关。有人为了方便调试把token通过query string传参比如/userInfo?tokenxxxx。这在本地开发看着很爽但风险极大URL会出现在浏览器历史记录、服务器访问日志、nginx日志、CDN日志、各种第三方分析工具里等于把钥匙复制了好几份贴在大门口。真正的token应该放在请求头里比如Authorization: Bearer token或者放在带HttpOnly属性的Cookie里。这里还有个容易被忽略的细节如果你的token放在localStorage里那么理论上任何能执行脚本的XSS漏洞都能把它偷走。这也是为什么部分安全要求高的项目会把token存进HttpOnlyCookie里让JS根本读不到。但HttpOnly Cookie的缺点是没法在JS里手动操作跨域场景也麻烦。所以在实际项目里token放哪要看你团队对XSS防护和跨域能力的信心没有绝对最优解只有权衡。3. 刷新页面后登录状态丢失的四个典型原因3.1 存储位置选错了sessionStorage的陷阱我见过最典型的“刷新丢登录”就是把token存进了sessionStorage。从名字上看sessionStorage好像和“会话”很配于是很多新手会下意识往里塞登录凭证。但它的生命周期是页面会话期间有效关闭标签页或浏览器后就没了用新标签页打开站点时它也是空的。刷新页面时sessionStorage不会丢但为什么很多人觉得“存了sessionStorage刷新就丢”其实真正的坑是浏览器崩溃恢复、隐身模式限制、以及用户习惯性关掉标签页再开——这些场景下sessionStorage就清空了用户感知就是“我明明登录过怎么又要输密码”。而localStorage是持久化的只要用户不主动清浏览器数据它就能一直在。对于“保持登录状态”这个需求来说首选持久化存储基本就是localStorage或者HttpOnly Cookie后面细讲。// 错误示范存sessionStorage sessionStorage.setItem(token, res.data.token) // 正确姿势存localStorage localStorage.setItem(token, res.data.token)但localStorage也不是高枕无忧它有另一个问题多个标签页之间没有自动同步机制。一个标签页登录了另一个标签页不知道一个标签页登出了清了localStorage另一个标签页还以为是登录状态。后面会专门讲怎么处理。3.2 初始化时序问题路由守卫等不到token这个坑更隐蔽。表现为你明明在localStorage里看到了token但刷新后还是被踢回登录页。问题往往出在初始化时序上。vue-router的路由守卫和应用的初始化是同步执行的但获取用户信息的请求是异步的。常见的错误写法是这样router.beforeEach(async (to, from, next) { const token localStorage.getItem(token) if (!token) { next(/login) return } // 这里拉用户信息假设是个异步请求 const user await api.getUserInfo() if (!user) { next(/login) } else { next() } })你看着逻辑没问题有token就拉用户信息没token就去登录页。但实际运行时如果用户信息接口返回401因为token过期了或者后端临时抽风这个守卫就会把用户踢回登录页。这不算“时序”问题算“判断逻辑不严谨”。真正的时序问题长这样应用启动时路由守卫先跑了但axios的请求拦截器还没挂载好导致api.getUserInfo()发出的请求没带上token后端返回401于是守卫误判“用户未登录”。这种问题在你是用了模块化加载、异步插件或者某些依赖注入顺序不当的时候特别容易发生。解决思路是把“初始化用户信息”这个动作从路由守卫里抽出来做成一个独立的、幂等的方法确保它在所有axios拦截器就绪后再执行路由守卫只判断“有没有token”尽量不做异步验证要验证也通过拦截器的全局错误处理统一做。3.3 Token过期后端直接给你401这是“刷新丢登录”里最符合逻辑的一种情况不是没存不是没读而是token真的过期了。前端要在登录状态这件事上做得让人“无感”核心就是自动续期。很多小项目图省事token有效期给得特别长比如30天、甚至一年但这是安全上的大忌——偷到一个能用一年的token等于拿到了全年通行证。正规做法是双token后面第4部分重点展开一个短期token用来访问接口过期时间控制在15分钟到2小时一个长期token用来换取新的短期token有效期可以是7天、30天。这样用户在使用过程中无感知长时间不用的会话才需要重新登录同时还能控制被盗用的风险。3.4 多标签页同步与localStorage被清空localStorage虽然持久但有一个特性经常被忽略它是按“来源origin”隔离的同一个浏览器、同一个域名下的所有标签页共享同一份localStorage但标签页之间不会有storage变化的实时通知实际上有storage事件但只在别的标签页里触发。想象一个场景用户开了两个标签页A标签页登录了B标签页还是登录页。用户在B标签页登录A标签页没有任何感知两个页面都认为自己是“当前登录态”。如果A页面发了一个登出接口清了localStorage里的tokenB页面的下次请求就会带着一个已经被后端作废的token然后被401再被自己的拦截器踢回登录页。处理多标签页登录状态同步主要有两个手段监听storage事件当其他标签页修改localStorage时本页面重新读取、更新内存里的登录状态不用localStorage直接用Cookie。Cookie在标签页间天然共享不需要手动同步但我得说句实话多标签页的“完全同步”很难做到完美因为很多操作比如登出本质上是服务端状态的变化前端只能靠请求结果来感知。实际项目里先把“刷新不丢登录”这个问题解决掉多标签页同步通常够用就行监听storage事件做强制下线或登出操作已经是相当完善的处理了。4. 7天免密登录的核心技术原理4.1 双Token体系为什么需要两个现在我们来聊“7天免密登录”这件事。先澄清一个概念这个词里的“免密”不是指“不用密码”而是指“在有效期内不需要重复输入密码”。7天免密登录的技术本质就是让前端保存一个能够“自我证明身份”的长期凭证在有效期内用它静默换取一个新的短期凭证。这里要用到双Token设计项目access_tokenrefresh_token作用请求业务接口的身份凭证换取新的access_token有效期短通常15分钟~2小时长7天~30天存储位置内存、localStorage、Cookie均可更敏感建议放HttpOnly Cookie或更安全的存储前端行为每次请求带上只在access_token过期时使用泄露风险相对较低相对较高必须重点保护为什么不让access_token直接给7天有效期反而搞两个token这么麻烦原因很简单安全与体验的折中。短期token如果被盗黑客能作恶的时间窗口很短长期token虽然更危险但我们可以把它藏得更深比如HttpOnly CookieXSS拿不到并且每次使用refresh_token都会换一个新的一旦发现异常可以直接吊销。4.2 免密登录的完整流程把双Token串起来7天免密登录的流程大概是这样用户输账号密码登录后端校验成功返回两个token一个7天有效期的refresh_token一个1小时有效期的access_tokenrefresh_token写入HttpOnly Cookieaccess_token放内存或localStorage用户正常使用接口都带access_token一切无感access_token快过期了前端在某个时机比如请求返回401用refresh_token去换取新的access_token后端验证refresh_token有效且没过期签发一个新的access_token用户连续使用了7天第8天refresh_token过期请求401前端清除登录状态跳转登录页用户需要重新输密码这套流程里最核心的细节是access_token失效后前端不能用“踢回登录页”当默认行为而是要先尝试静默续期。只有refresh_token也失效时才真正需要用户重新登录。4.3 axios拦截器无感续期的落地实现在Vue项目里续期动作通常放在axios的响应拦截器里做。思路非常简单收到401就调刷新接口刷新成功就重发原来的请求刷新失败才跳登录页。// 伪代码重点看思路 let isRefreshing false let waitingQueue [] service.interceptors.response.use( response response, error { const { response, config } error if (response.status 401 !config._retry) { if (isRefreshing) { // 已经有请求在刷新token了把其他401请求挂起等刷新完重放 return new Promise(resolve { waitingQueue.push(() { config._retry true resolve(service(config)) }) }) } isRefreshing true config._retry true return refreshToken() .then(newToken { updateToken(newToken) waitingQueue.forEach(cb cb()) waitingQueue [] return service(config) }) .catch(err { waitingQueue [] redirectLogin() return Promise.reject(err) }) .finally(() { isRefreshing false }) } return Promise.reject(error) } )那段waitingQueue的设计是很容易被忽略的细节并发请求同一时间全部401时如果每个请求都去调刷新接口会打出好几发刷新token请求白白浪费网络还会因为重复使用refresh_token导致后端强制下线。正确的做法就是加一个锁让第一个401请求去刷新token其余的排队等新token回来再重发。4.4 安全是极限拉扯有效期到底怎么定“7天免密登录”里的7天并不是拍脑门定的数字。它取决于你的业务风险承受能力和用户预期金融、支付类应用免密窗口极短甚至禁用每次都要重新验密或二次验证工具类、内容类应用7天~30天比较常见企业内部系统可能直接做“记住我”勾选不勾就关闭浏览器即失效勾了就7天有效另外refresh_token最好是**一次性rotate**的每次用它换新token时旧refresh_token立刻作废。这样即便refresh_token被截获黑客拿到时可能已经失效或者旧token被用了两次会触发服务端警报。后端还要记录每个refresh_token的“父token”一旦发现链被续得太长或者出现异常跳跃就判定为盗用强制全部下线。注意任何“7天免密”方案里登出操作必须同时清除本地token和后端refresh_token的会话记录。很多人以为登出就是前端清一下localStorage结果旧token还能用被安全测试一抓一个准。5. 实操一套可落地的登录状态保持方案5.1 先封装一个token读写模块不管用什么存储先把操作集中封装起来别在代码里到处写localStorage.getItem(token)。这后面要改存储方式、要做多标签页同步都只需要改一个文件。// utils/token.js const ACCESS_TOKEN_KEY app_access_token const REFRESH_TOKEN_KEY app_refresh_token export function getAccessToken() { return localStorage.getItem(ACCESS_TOKEN_KEY) } export function setAccessToken(token) { localStorage.setItem(ACCESS_TOKEN_KEY, token) } export function getRefreshToken() { return localStorage.getItem(REFRESH_TOKEN_KEY) } export function setRefreshToken(token) { localStorage.setItem(REFRESH_TOKEN_KEY, token) } export function clearTokens() { localStorage.removeItem(ACCESS_TOKEN_KEY) localStorage.removeItem(REFRESH_TOKEN_KEY) }这里只演示了localStorage的版本。如果项目里有HttpOnly Cookie存refresh_token那么getRefreshToken/setRefreshToken就换成接口调用的方式比如POST /auth/refreshtoken本身从Cookie里自动带上前端JS根本不需要去读它。声明这套接口是为了让你在换存储方案时不用翻遍整个src目录。5.2 路由守卫 初始化用户信息路由守卫的职责要尽量单一只做“有没有token”的判断不做“token是否有效”的验证因为中间要跑异步接口时序问题容易缠成一团。import router from ./router import { getAccessToken } from /utils/token import { fetchUserInfo } from /api/user router.beforeEach(async (to, from, next) { const token getAccessToken() if (!token) { if (to.path /login) { next() } else { next({ path: /login, query: { redirect: to.fullPath } }) } return } if (to.path /login) { next(/) return } // 用户信息还没初始化才去拉取 if (!store.state.user.info) { try { const user await fetchUserInfo() store.commit(setUser, user) } catch (e) { // token失效清除本地凭证跳登录 clearTokens() next({ path: /login, query: { redirect: to.fullPath } }) return } } next() })这段代码的关键点有几个用户信息是异步拉取的但只要拉到过一次就存进Vuex/内存后面不再重复请求。刷新后内存清空重新拉一次但这次拉取发生在路由守卫里axios拦截器此时已经挂载完毕这个时序要保证办法是确保引入路由守卫这个模块的副作用执行顺序在axios拦截器之后跳转登录页时带上redirect参数登录成功后能回到原来的页面体验细节拉取用户信息失败说明token大概率挂了这时候再去走刷新token的逻辑而不是直接把用户踢走有个非常容易犯的错在路由守卫里一看到401就跳登录页。401不代表登录失效它可能是access_token过期refresh_token还活着。真正该做的动作是“尝试刷新token刷新失败再跳登录页”。5.3 让axios拦截器去解决401前面已经贴了刷新token的核心拦截器代码。这里补充几个实现要点刷新接口本身不能被拦截器递归拦截。如果刷新接口也返回401千万别让它再走进“刷新token”的逻辑否则就是死循环。通常直接在拦截器里判断请求的url或者给刷新请求加上特殊标记。等待队列要防止内存泄漏。挂起的请求如果一直等不到token刷新完最后要清空队列并给所有挂起请求返回错误不能让请求永久pending。刷新token也失败时要清空所有本地token跳登录页并且别弹一堆错误的toast。用户感知应该是“登录过期请重新登录”而不是一堆401红色报错。// 刷新接口打上标记避免递归 const REFRESH_URL /auth/refresh service.interceptors.response.use(null, error { const { url, config } error if (url REFRESH_URL) { clearTokens() redirectLogin() return Promise.reject(error) } // 其他401逻辑... })这个“刷新接口自己不能再触发刷新”的坑我踩过不止一次。第一次上线的时候刷新token请求被别人误加了Authorization头后端返回401然后又触发了刷新逻辑——两个刷新请求互相嵌套控制台直接刷屏。5.4 常见问题排查速查表把这些年遇到过的相关问题的排查步骤整理成了一张表配合排查链路用效率很高现象排查步骤解决方案刷新后被踢回登录页localStorage没有token检查登录成功后是否真的执行了setAccessToken在登录页的提交回调里断点确认响应数据和存储代码都执行了localStorage有token但还是跳登录页检查路由守卫是否异步拉用户信息失败406 刷新token机制或把用户信息拉取放到守卫中的try/catch并区分401和网络错误接口401但路由不跳登录页检查axios拦截器是否正确捕获了401统一在响应拦截器处理401别在页面代码里单独catch刷新token后并发请求大量失败检查是否有等待队列去重用isRefreshing标志 请求队列只让一个请求去刷新多标签页一个登出全部掉线检查storage事件是否监听以及Cookie方案是否可行监听storage事件登出时主动通知其他标签页浏览器关闭再打开需要重新登录token存进了sessionStorage而不是localStorage换成localStorage/Cookie隐身模式下刷新丢登录localStorage在隐身模式下可能被限制或清空两种方案继续用localStorage并提示用户隐私模式的限制或改用基于Cookie更稳定但更麻烦的存储5.5 我踩过的几个坑单独聊几个实操里容易翻车的地方。第一个坑登录后在路由守卫里“立刻”拉用户信息。路由跳转比接口响应快得多用户到了首页但页面因为用户信息还没加载完会渲染出一堆空数据。我们后来加了全局的initData方法在应用初始化完成后统一拉取基础信息而不是只在路由守卫里干活。这样逻辑清晰页面每个组件的加载也不依赖路由时序。第二个坑刷新token使用了自己的localStorage。如果refresh_token存在localStorage里而你的站点被植入了一个XSS脚本黑客可以直接替用户发起刷新请求把所有信息偷走。后来我们给refresh_token改成了HttpOnly Cookie前端彻底读不到访问接口时浏览器自动带上安全性上升了一个台阶。代价是跨域时Cookie要配SameSiteNone; Secure稍微麻烦一点。第三个坑忘了处理“登出其他标签页”。用户在一个标签页登出另一个标签页还在正常操作点按钮时API返回401才一脸懵地发现自己被踢了。这种感觉很割裂。我们后来在所有页面的mounted里监听了storage事件一旦发现token键被移除立刻跳转登录页并提示“已在其他窗口登出”。第四个坑刷新token时死循环。这个前面提过刷新接口本身也会返回401如果不做特殊处理无限递归会把浏览器网络请求刷爆。一定要在拦截器里判断URL或者给刷新请求加一个_refreshFlag。6. 写在最后这套方案的取舍与扩展如果项目刚起步、用户量不大可以不用上双Token那么重的一套体系先做一个“长效token 路由守卫 localStorage”的简化版也能跑。但如果你问我在实际项目里最后用的是哪套方案我会说access_token放内存每次刷新页面自动续期refresh_token放HttpOnly Cookie7天有效期配合token轮换登录接口记住登录状态。这套方案兼顾了安全性、体验和可维护性虽然写起来比无脑存localStorage多几百行代码但换来的是产品不用跟用户反复解释“为什么我又要登录了”。再补充一个小经验登录状态的保存方案一定要在项目一开始就定下来别等上线后再改。改存储方案看着只是换一个API实则涉及路由守卫、请求拦截器、多标签页同步、后端会话策略四处都要跟着动。我在一个项目上线第二个月时做过一次从sessionStorage改到Cookie的迁移改了三天还出了一堆线上bug。如果能在代码评审阶段就把这套链路走一遍后面能省非常多的心。7天免密登录这件事往深了做还能引申出设备管理、异地登录提醒、登录日志审计、多端互踢等功能。但只要把“刷新不丢登录”“401自动续期”“logout清干净”这三件事做扎实你的Vue项目在登录这块就已经达到中上水准了。
返回列表