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

资讯详情

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

从登录到鉴权:前端用户认证模块的完整设计与避坑指南

从登录到鉴权:前端用户认证模块的完整设计与避坑指南 有段时间我接手了一个中后台项目登录功能看着是正常的输账号密码能进右上角也有退出按钮。但真正用起来就处处不对劲——早上登录一次下午点某个菜单直接白屏打开两个标签页一个退出登录另一个继续点提交还能成功还有一次排查了半天发现是token过期时间在前端配置的时区和服务端对不上导致接口偶尔飘401。这些问题的根子几乎都指向同一个地方用户认证模块的设计。这也是为什么我特别想把从登录到鉴权、从前端存储到路由守卫的完整链路单独拿出来讲一章。这套内容不挑框架Vue、React、原生JS都能适用核心思路是一致的。我会结合自己在实际项目里的取舍和踩坑经验来写希望对正在做后台管理系统、微前端项目或者正在准备前端面试的朋友都有帮助。1. 认证与授权先分清楚登录成功只是起点不是终点1.1 认证、授权与凭证的三件事在实际项目里很多人把“认证”和“授权”混在一起说但前端代码里这两者的处理逻辑完全不同。认证Authentication解决的是“你是谁”的问题用户名密码对不对、验证码对不对最终系统给你发一个凭证token或sessionId。授权Authorization解决的是“你能干什么”的问题登录之后当前用户能看哪些菜单、能触发哪些按钮、能访问哪些路由这是基于角色和权限的判定。我们说的“用户认证模块”既要管认证也要管授权。登录环节走的是认证拿到凭证之后的路由守卫、按钮权限、接口访问控制属于授权。但很多项目把登录做完了就以为认证模块结束了后面的菜单权限、按钮权限完全没做导致登录用户能用URL直接访问任何页面。这个问题在后台管理系统里尤其常见。凭证本身也要选对模式。Session模式里凭证存在服务端内存或Redis里前端拿到的是sessionId每次请求带着它服务端查到session才认账。Token模式则是服务端把一个带签名和过期信息的字符串发给前端前端存下来每次请求把它带上去服务端验签即可。两种模式对前端存储、请求携带、失效处理的要求完全不同下面这个表格理一下凭证模式存放位置前端携带方式失效策略典型技术Session服务端内存/RedisCookie里的sessionId服务端删除sessionExpress-Session、Django SessionToken前端本地存储Authorization Headertoken过期 refresh刷新JWT、OAuth2Token不一定非得是JWTJWT只是最常用的令牌格式。它自带签名和过期时间适合分布式系统但有一个绕不开的问题签发之后在过期之前服务端无法主动让它失效除非额外引入黑名单机制。理解了这一点也就明白为什么现在主流方案都要做短期access_token加长期refresh_token的双token组合。我建议开始动手前先画一张简单的角色权限表列出系统里有几种角色、每种角色能访问哪些页面、能操作哪些接口。这一步能帮你把认证和授权的边界理清楚别急着写代码。1.2 一次完整的登录生命周期长什么样把登录的完整链路拉出来看前端在其中承担的任务清晰很多用户在登录页输入账号密码前端做基础校验非空、格式、长度。前端把凭证信息发送给后端认证接口。后端校验用户信息签发access_token和refresh_token。前端收到两个token选择合适的方案存储。前端把用户基本信息写入全局状态Pinia/Vuex/Redux并同步到本地缓存。前端跳转到业务首页或由路由守卫放行。用户在页面里发起业务请求请求拦截器自动在header里带上token。服务端校验token通过返回业务数据不通过响应拦截器触发刷新token或重新登录。用户登出前端清理所有本地凭证和状态回到登录页。这套链路里前端能控制的环节是1、4、5、6、7、8、9后端只负责3和8。很多前端面试题问“前端如何实现用户认证”考的就是这条链路上每个环节是否考虑周全而不是只问“怎么把token存起来”。这个生命周期里每一个步骤都有对应的坑。第4步的存储方案选择会牵扯到安全性和多标签页同步第5步没做好刷新页面就会丢用户信息第7步的拦截器写得不好会出现重复刷新token的并发问题第8步的处理直接决定了用户会不会在接口报错时被莫名踢出。下面一节一节展开讲。2. 登录请求这一层校验、封装和异常处理一个都不能少2.1 登录表单校验别把所有事都扔给后端很多初学者习惯把校验全部交给后端前端表单随便填点登录按钮把数据发出去等后端返回“密码不能为空”再弹个框。这样体验很差还会白白增加无效请求。做登录表单校验时要注意的点必填项校验账号、密码、验证码这些字段不能为空用表单校验库VeeValidate、Element Plus的Form规则、Ant Design Form的rules在失焦时立刻提示别等提交时才报。格式校验手机号是11位数字、邮箱符合基本格式用正则实现。这类校验简单但很能提升体验。长度限制密码一般限6到32位。如果不限长度后端又做了截断会出现“前端显示输了很长实际后端收到的是截断后的值”这种诡异问题。错误提示要友好不要把后端的技术错误堆栈直接弹出来要转成用户能看懂的中文提示比如“账号或密码错误”而不是“Errcode 10086”。值得强调的是前端校验只是体验优化不是安全措施。恶意用户完全可以绕过页面直接用工具调接口。真正的数据合法性校验必须依赖后端前端校验做得再漂亮也不能替代后端校验。2.2 登录接口的请求封装与超时处理项目里一般会封装一个request工具统一处理baseURL、超时时间、请求拦截、响应拦截。登录接口比较特殊因为它还没有token属于免鉴权接口但异常处理一点也不能含糊。// request.js —— 一个基础的axios封装 import axios from axios import { message } from ant-design-vue const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) // 请求拦截正常业务请求带上token登录接口不需要 service.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token !config.url.includes(/auth/login)) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截统一处理错误状态 service.interceptors.response.use( response response.data, error { const status error.response?.status if (status 401) { // 这里可以触发刷新token或跳转登录页 window.location.href /login } message.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } ) export default service细节上要注意几点。第一超时时间要根据业务调整登录接口通常比普通查询接口多预留几秒用户弱网环境下3秒超时容易误判。第二登录按钮要防重复提交——接口没返回之前把按钮置为loading状态loading建议放在请求层统一管理而不是每个页面重复写。第三请求失败时要区分网络错误没有状态码和服务端错误4xx/5xx两者提示文案不一样。2.3 拿到token之后的“黄金三秒”登录接口返回后建议按固定顺序处理而不是随手往localStorage一丢就跳转校验返回的数据结构确认存在access_token字段不存在说明接口方有变动立即抛出明确错误信息。把access_token和refresh_token写入存储方案。把用户基本信息同步到全局store里。如果登录接口返回的用户信息不完整调用一次获取用户信息接口拿最新角色、权限和头像。跳转到业务首页或redirect参数指定的页面。为什么要强调顺序因为跳转之后首页很可能会立即发起大量业务请求请求拦截器读取token的时机就在跳转后的第一次请求里。如果第2步和第3步没做完就跳转会出现首页请求401、用户信息为空导致页面空白的问题。另外登录成功后要把地址栏里的redirect参数记下来。比如用户访问需要登录的页面时被拦截到登录页登录成功后应回到他原本想去的页面而不是固定回首页。这个体验细节很常见但很多人会漏。3. token存储方案localStorage、sessionStorage与cookie的三角博弈3.1 三种存储方式怎么选“前端登录后把token存哪里”这是面试和实际开发都绕不开的问题。三种常见方式各有利弊存储方案持久性XSS风险CSRF风险典型场景localStorage浏览器关闭后仍在高脚本可读取无不会自动携带中后台系统、SPA应用sessionStorage标签页关闭后清除高脚本可读取无不会自动携带单标签页应用、安全性要求偏高场景cookieHttpOnly由Expires/Max-Age控制低脚本读不到有自动携带需CSRF防护传统Session模式、需服务端主动控制时localStorage方案简单直接项目里用得最多。但XSS风险是实打实的——如果页面被注入了一段恶意脚本任何脚本都能直接读取token并偷走。sessionStorage稍微好一点换标签页就失效但同样能被脚本读取。HttpOnly cookie安全性最高因为脚本根本读不到Cookie的值但需要后端配合配置Cookie属性且接口会天然携带CookieCSRF防护工作不能省。实际选型我的倾向是纯前端SPA、后端完全走Authorization Header的项目用localStorage加短期access_token加refresh_token组合最省心资料多、排错方便对安全性要求很高的项目优先考虑HttpOnly cookie方案。没有银弹按场景取舍。3.2 access_token与refresh_token的双token机制怎么落地只用一个不设过期时间的token看起来方便但一旦泄露坏人可以长期有效。所以主流做法是双tokenaccess_token有效期短一般15分钟到2小时前端请求时放进Authorization header。refresh_token有效期长一般7天到30天专门用来换取新的access_token。当业务请求返回401时前端用refresh_token调刷新接口拿到新的access_token后重放刚才失败的请求。用户无感知不用重新登录。这部分的并发处理是重头戏// 刷新token的核心逻辑简化版 let isRefreshing false let pendingQueue [] async function refreshAccessToken() { const refreshToken localStorage.getItem(refresh_token) const res await axios.post(/auth/refresh, { refresh_token: refreshToken }) localStorage.setItem(access_token, res.data.access_token) return res.data.access_token } service.interceptors.response.use( response response.data, async error { const { config, response } error if (response?.status 401 !config._retry) { if (isRefreshing) { // 已有刷新流程在跑把当前请求排队 return new Promise(resolve { pendingQueue.push(newToken { config.headers.Authorization Bearer ${newToken} resolve(service(config)) }) }) } config._retry true isRefreshing true try { const newToken await refreshAccessToken() pendingQueue.forEach(cb cb(newToken)) pendingQueue [] config.headers.Authorization Bearer ${newToken} return service(config) } catch (refreshError) { // 刷新失败清理凭证并跳转登录页 localStorage.removeItem(access_token) localStorage.removeItem(refresh_token) window.location.href /login return Promise.reject(refreshError) } finally { isRefreshing false } } return Promise.reject(error) } )这段代码里最容易翻车的是并发处理。同一个页面可能同时发好几个请求第一个请求触发401后开始刷新token后面几个请求也陆续返回401。如果不加isRefreshing和pendingQueue机制就会同时发起多个刷新请求refresh_token被重复使用。很多后端刷新接口对refresh_token做了单次使用限制重复使用会导致全部token失效用户只能重新登录。refresh_token本身也要防滥用。有些团队会为refresh_token绑定设备信息、IP、UA一旦发现异常直接踢下线。前端能做的是当刷新接口本身返回401时不要反复重试立即清理本地状态跳登录页。3.3 401和403必须分开处理很多前端把401和403混为一谈权限不足时也弹“请重新登录”误导用户。在响应拦截器里要明确区分401 Unauthorized未认证或token失效。处理方式是刷新token刷新失败再跳登录页。403 Forbidden已认证但无权访问。处理方式是提示“没有权限”不要清token也不要跳登录。我见过一个项目把403当成401处理用户点一个没权限的菜单直接被踢回登录页排查了很久才发现后端返回的是403被前端统一拦截成401了。这类问题数据统计里特别难发现只能靠日志排查。4. 路由守卫与请求拦截两道闸门把控制权握在前面4.1 路由守卫里的登录态判断与白名单处理登录态存好之后前端的第一道闸门是路由守卫。以Vue Router为例// router/index.js import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(), routes: [ { path: /login, component: () import(/views/Login.vue), meta: { public: true } }, { path: /dashboard, component: () import(/views/Dashboard.vue) }, { path: /admin, component: () import(/views/Admin.vue), meta: { roles: [admin] } } ] }) router.beforeEach((to, from, next) { const token localStorage.getItem(access_token) const userStore useUserStore() // 白名单页面登录、注册、忘记密码等不校验登录态 if (to.meta.public) { return token to.path /login ? next(/dashboard) : next() } // 未登录跳登录页并带上redirect if (!token) { return next({ path: /login, query: { redirect: to.fullPath } }) } // 已登录但store里没有用户信息时先拉取一次 if (!userStore.user) { // 调用getUserInfo根据返回的角色信息判断是否允许访问 } next() })路由守卫要做两层判断第一层是“是否登录”第二层是“是否允许访问”。很多项目只做了第一层登录用户直接修改URL就能访问没有权限的页面。虽然最终接口权限还在后端把关但前端路由不控制体验上有明显的漏洞感。React Router 6.4之后没有官方的beforeEach概念一般用一个PrivateRoute组件包裹受保护页面渲染前检查token和权限逻辑完全相同。核心思路没有技术债只是写法不同。4.2 菜单和按钮级别的权限控制怎么做登录之后能看哪些菜单、能操作哪些按钮属于授权范畴。常见做法是后端返回该用户的可访问资源列表前端据此动态生成菜单和按钮。动态菜单的实现思路登录成功拿到roles和权限列表后在路由守卫里动态addRoute而不是一开始就把全部路由注册进去。这样未授权路由即使被人猜到地址路由匹配也根本找不到配合后端接口鉴权属于双保险。按钮级别的控制更细。可以用自定义指令v-permission也可以写一个hasPermission工具函数export function hasPermission(requiredPermission) { const userStore useUserStore() const permissions userStore.permissions || [] return permissions.includes(requiredPermission) } // 模板里使用 // Button v-ifhasPermission(user:delete)删除/Button这里有个坑页面里用v-if缓存了权限判断结果当用户切换账号或者权限变更时必须重置权限状态否则旧权限会残留。我在项目里踩过一次管理员退出后换成普通用户登录页面状态没重置普通用户还能看到管理员专属的删除按钮点一下就报403体验非常奇怪。4.3 请求拦截器里除了token还能做什么请求拦截器的主要工作是把token塞进Authorization header但实际项目里可以顺带做几件事统一加时间戳参数防止GET请求被浏览器缓存污染数据。在debug环境打请求日志方便本地排查问题。对请求参数做统一预处理比如日期字符串转时间戳。把当前请求放入“进行中请求集合”用于全局loading或取消重复请求。不过这类附加能力要克制别把拦截器写成万能垃圾场。每个动作拆成小函数单独维护保持可读性不然三个月后自己回来都看不懂。5. 用户信息管理与登出清理最容易翻车的最后一公里5.1 用户信息的获取时机与缓存策略用户基本信息在登录成功后获取一次放进全局store同时用localStorage或sessionStorage做一份持久化。刷新页面时store被清空要从本地缓存恢复或者重新请求一次用户信息接口。我的经验建议是权限相关数据不要只依赖本地缓存页面刷新后最好重新请求一次。因为管理员可能在上次登录期间改了权限本地缓存的还是旧数据。做法是在路由守卫里判断“store里没有用户信息且存在token”时先请求用户信息再放行页面。另一个细节是用户信息接口的失败处理。token还在但用户信息接口返回401说明token已经失效同样要触发刷新逻辑。很多项目只对业务请求做了401处理忽略用户信息接口结果出现“首页正常但个人信息加载不出来”的割裂状态。5.2 登出清理要覆盖的完整位置登出不是简单的“清空localStorage再跳登录页”。一个完整的登出清理动作应该覆盖清理access_token、refresh_token。清理本地缓存的用户信息、权限列表。重置全局store到初始状态。有WebSocket连接时主动断开。有定时器、轮询任务时停止。清空动态添加的路由记录避免切换账号时残留旧路由。用上面的清单对照一下项目很多项目只做了前两三项就会出现“退出后换账号登录还能看到上一个账号的菜单”这种低级bug。前端清理完以后还要调后端登出接口。后端登出接口的作用是让服务端把refresh_token标记失效这样即使别人拿到旧refresh_token也无法换新token。这个动作要放在页面跳转之前用await等它完成弱网环境下即使失败也要继续跳转不能因为接口超时把用户锁在页面上。5.3 多标签页的登录态同步浏览器允许用户同时开多个标签页每个标签页有独立的JS运行环境但共享localStorage和cookie。于是在一个标签页退出登录另一个标签页就是“失联”状态。解决方案是监听storage事件window.addEventListener(storage, event { if (event.key access_token !event.newValue) { useUserStore().resetUser() router.push(/login) } })storage事件有个特点只有其他标签页修改localStorage时才会触发当前标签页自己修改不会触发刚好避免重复处理。另一个相关坑点是sessionStorage不跨标签页共享。如果用了sessionStorage存token用户复制链接新开标签页新页面里没有token会出现“明明登录了却要重新登录”的问题。如果产品需求允许“新标签页重新登录”那没问题否则请用localStorage。6. 安全加固不能省把认证模块的底线画出来6.1 敏感信息别硬编码前端保不住秘密认证相关代码里最容易犯的错是把接口密钥、盐值之类直接硬编码在源码里。前端代码任何人都能看到打包之后的JS文件在浏览器里也能翻到。凡是想藏的信息都别指望放在前端能保密。一个实际案例某个项目把短信平台的API密钥写进了前端代码后来被爬虫从Source面板里翻出来导致短信接口被狂刷。后续改成由后端转发请求、前端只调自己的后端接口才把问题解决。前端能做的安全姿势有两类。一类是降低凭证泄露后的影响范围access_token短期有效、refresh_token用后即弃、日志和错误监控里不打印token、错误信息不暴露凭证内容。另一类是提高攻击成本给登录接口加图形验证码、短信验证码或限流措施防止暴力破解。6.2 XSS和CSRF两座大山的实际对策XSS跨站脚本攻击对认证模块的威胁在于恶意脚本可以读取localStorage里的token。对策包括服务端对输出内容做转义、前端对用户输入做过滤、加CSP内容安全策略限制可加载的脚本来源。一个基础的CSP响应头配置长这样Content-Security-Policy: default-src self; script-src self unsafe-inline https://cdn.example.com它告诉浏览器页面资源只从自己的域名加载脚本来源限本域和指定CDN第三方脚本默认拦截。配置CSP之后即使XSS注入成功能加载的外部脚本源也大幅收缩。CSRF跨站请求伪造主要威胁Cookie方案用户登录后浏览器自动携带Cookie恶意网站可以诱导用户发出高危请求。解决办法一般是校验Referer/Origin、使用自定义请求头、配合CSRF Token。对于纯SPA加Authorization Header的方案CSRF风险天然较低因为请求头不会自动携带攻击者无法在跨域请求里自定义Authorization头。所以实际项目里把token放localStorage配合Authorization Header主要防的是XSS选Cookie方案重点防的是CSRF。每种方案都有自己的主线战场别混淆。6.3 认证模块不是前端的独角戏用户认证是前后端协作的产物。前端做的所有事情——隐藏按钮、拦截路由、清理缓存——都只是展示层策略真正的安全边界必须由后端守住。后端必须兜底的事情包括登录接口频控、密码加盐哈希、token签名与过期校验、refresh_token轮换与吊销、关键接口二次校验、操作审计日志。前端做得再花哨只要后端这些点漏了任何一个整个认证体系就是纸糊的。另外可以给项目做一个认证方案检查清单对照自查access_token过期时间是否设为短周期。refresh_token是否支持轮换与吊销。登录失败是否有频控和验证码机制。前端日志和监控是否有token脱敏。登出动作是否覆盖所有本地状态和远程会话。多标签页登录态是否同步。路由守卫、请求拦截、按钮权限三层是否都做了。这套认知是我做了几个中后台项目、给线上应用补了一堆安全漏洞之后才彻底想明白的。如果你正在做用户认证模块建议别急着只写登录代码先跟后端把token的生命周期、过期时间、刷新策略、登出吊销机制对齐好再回来写前端会顺畅很多。我自己在多个项目里踩完这些坑后最大的体会是认证模块最怕的不是代码难写而是边角细节太多。把token的来龙去脉、失效逻辑、清理路径想清楚整个模块就稳了一大半。下次你要是遇到接口莫名其妙401、刷新页面丢登录态、退出登录不彻底这类问题按这条思路去排查比上网翻零散帖子高效得多。
返回列表