JWT登录+Vuex状态管理+验证码解锁:SPA项目认证实战

发布时间:2026/9/2 8:18:35
JWT登录+Vuex状态管理+验证码解锁:SPA项目认证实战 做八股文网站这类项目时总绕不开一个核心模块登录认证。我在自己折腾“面试刷题站”的过程中把验证码解锁、JWT登录、Vuex状态管理这一整套流程从零撸了一遍踩了不少坑也把底层原理摸透了。这篇就把完整的技术方案、实现细节和那些文档里不会写的经验教训一次性讲清楚给正在做SPA项目、或者面试前想搞懂认证机制的朋友做个参考。1. 项目整体设计与思路拆解1.1 为什么选“验证码JWT”这套组合八股文网站这类内容型SPA应用最核心的用户体系诉求其实很朴素让用户注册、登录然后根据登录状态提供差异化功能——比如收藏题目、记录刷题进度、提交答案等。而安全层面的痛点也很明确登录接口如果裸奔很容易被脚本暴力撞库或者被恶意刷验证码接口。我当时定方案的时候第一反应是传统的Session-Cookie模式。但仔细一琢磨这个方案在后端需要维护会话状态如果将来要把服务拆分成多个实例做负载均衡还得额外引入共享Session存储比如Redis复杂度直接上来。而JWTJSON Web Token是自包含的、无状态的服务端只需验证签名天然适合SPA项目前后端分离的场景也方便以后做多端复用。至于验证码核心目的不是“防机器人”真正的强验证请上行为验证码而是提高暴力破解的成本。配合登录失败次数限制可以非常有效地挡住大部分无脑扫描和撞库脚本。我用的是图形验证码方案后端生成图片、把答案存Redis、给前端返回一个UUID作为凭证前端提交登录时带上这个UUID和用户输入的验证码后端比对。这套组合的逻辑很简单验证码负责“你是不是人”JWT负责“你是谁、权限是什么”Vuex负责“前端怎么管理你的登录态”。1.2 SPA项目里Token安全面临的现实问题在设计这套方案之前必须先看清楚SPA项目里Token面临的几个现实问题不然设计出来的方案就是空中楼阁。首先是存储问题。JWT拿到手放哪儿localStorage方便但XSS一打就丢内存里安全但刷新就没了。其次是过期与续签。JWT是无状态的一旦签发服务端在它自然过期前没法主动作废这就是无状态特性的双刃剑。第三是并发请求时的401处理。如果Token在多个请求同时发出时过期了前端如果每个请求各自去刷新Token会产生竞态条件后端会被刷爆。这些问题不是理论推演是我实际开发中真真切切遇到的问题。所以最终的方案不是简单“发个Token就完事”而是要做一整套配套机制前端用Vuex管理Token同时持久化到localStorage后面细说为什么不能只存一边后端采用双Token机制AccessToken短时效RefreshToken长时效刷新接口负责续期前端用Axios响应拦截器统一处理401配合单例刷新机制避免并发竞态1.3 整体架构分模块拆解整个项目从模块角度看大致分了三块模块核心职责关键技术点验证码服务生成、存储、校验Java的BufferedImage绘制图片Redis存储答案JWT认证服务签发、校验、刷新jjwt库HMAC-SHA256签名双Token机制前端登录流程调用接口、管理状态、路由控制Vue2 Vuex Axios vue-router这个划分不是随便分的每个模块边界清晰独立演进。比如验证码服务以后想升级成滑块验证码只需要替换这一个服务对外接口不变前端不用动。这就是模块化设计带来的好处。2. JWT登录机制核心原理拆解2.1 一段代码看懂JWT的结构JWT本质上就是一个经过签名处理的JSON字符串由三部分组成用点号分隔Header.Payload.Signature第一段Header声明令牌类型和签名算法{ alg: HS256, typ: JWT }第二段Payload存放实际的数据比如用户ID、用户名、过期时间。注意这一段是Base64Url编码的不是加密的任何人拿到都能直接解码看到内容。所以千万不要把密码等敏感信息塞进去。{ sub: 1234567890, name: zhangsan, iat: 1516239022, exp: 1516242622 }第三段Signature是把前两段的编码结果用密钥做HMAC-SHA256签名后得到的。这段存在的意义就是保证整个Token在传输过程中没有被篡改过。用代码来演示一下签名生成过程// 示意代码实际开发用jjwt库 String header Base64UrlEncoder.encode({\alg\:\HS256\,\typ\:\JWT\}); String payload Base64UrlEncoder.encode({\sub\:\123\,\exp\:1516242622}); String signature HmacSHA256(header . payload, secretKey); String jwt header . payload . signature;写代码的时候不建议自己撸这些底层用库就行。Java后端我用的是jjwtio.jsonwebtoken前端在线调试的时候用jwt.io这个工具就能肉眼验证Token内容。提示签名用的密钥一旦泄露等于所有Token都可以被伪造。这个密钥一定要放到服务端环境变量或配置中心绝对不要写死在代码里更不要传到前端仓库。2.2 签发与校验的完整时序登录成功的后续流程是这样的前端把用户名、密码、验证码、验证码UUID一起POST到/api/auth/login后端先校验验证码从Redis按UUID取出来比对比对完立刻删除防止重放验证码通过后再校验用户名密码密码要用BCrypt做哈希存储校验通过后用密钥签发AccessToken有效期2小时和RefreshToken有效期7天把两个Token返回给前端前端交给Vuex管理并持久化后续请求在请求头带上Authorization: Bearer AccessToken后端写一个过滤器对所有需要认证的接口校验Token签名、过期时间校验通过后把用户信息放入请求上下文供Controller直接取用校验的时候核心逻辑就两句话签名对不对、过期没有。所以JWT的校验是O(1)的不需要查数据库这也是它天生适合高并发场景的原因。2.3 为什么用JWT而不是Session这个问题面试被问烂了但实际做项目的时候才有体感。Session方案用户登录后服务端生成一个SessionId存一份Session数据在服务端可能放内存也可能放Redis把这个SessionId种到Cookie里发给浏览器。后续请求浏览器自动带上Cookie服务端比对SessionId找到对应的Session数据。JWT方案用户登录后服务端签发一个自包含的Token客户端存着后续请求手动放在请求头里。服务端只校验签名和过期时间完全不关心客户端是谁、Token是发给谁的。对比一下维度SessionJWT服务端存储需要占内存/Redis不需要无状态水平扩展需要共享存储天然支持跨域要处理CORS带Cookie请求头携带无压力移动端适配一般友好主动失效方便直接删Session麻烦只能等过期安全风险CSRF因Cookie自动携带XSS因放localStorage最直观的体感是部署的时候Session方案多起一个服务实例如果不能共享Session用户的登录态就到处漂移JWT方案随便起多少实例只要密钥一致任何实例都能验证Token。2.4 Token续签与主动失效的实现方案纯JWT有个很烦人的点Token一旦签发在过期前是没法主动作废的。用户修改密码后旧的Token依然有效这显然有安全风险。这就是我引入双Token机制的原因。AccessToken设置短时效我设2小时负责日常请求认证RefreshToken设置长时效7天只用来调刷新接口换新的AccessToken。用户每次操作时如果AccessToken过期了前端用RefreshToken去换新的AccessToken用户无感知。刷新接口的核心代码逻辑PostMapping(/refresh) public Result refresh(RequestBody RefreshRequest request) { // 1. 校验RefreshToken是否有效签名过期时间 // 2. 拿RefreshToken里的userId查Redis里存的refreshToken进行比对 // 3. 不一致说明RefreshToken可能被重放直接拒绝 // 4. 一致则签发新的AccessToken同时把新的RefreshToken也签发并存储 // 5. 返回两个新Token }这个方案解决了一个容易被忽略的问题RefreshToken轮换。每次刷新都换发新的RefreshToken正常情况下一个RefreshToken只使用一次。如果攻击者盗取了一个RefreshToken但用户还在正常用那么用户下一次刷新时就能发现新的RefreshToken被提前使用了后端可以立刻判定有异常把这对Token全部作废。注意RefreshToken需要服务端存储我是放Redis的。这看起来和“无状态”矛盾但这个“有状态”只发生在刷新接口这个窄入口而且失效能快速止损这个复杂度是值得的。3. 验证码解锁模块设计与实现3.1 验证码在后端怎么生成和存储八股文网站的登录页验证码模块我分了三步实现。第一步是生成验证码图片。用Java的BufferedImage直接在内存里画掺入干扰线和噪点。核心代码// 生成4位随机数字 String code String.valueOf((int)((Math.random() * 9 1) * 1000)); // 创建画布 BufferedImage image new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g image.createGraphics(); // 绘制背景色 g.setColor(Color.WHITE); g.fillRect(0, 0, width, height); // 绘制干扰线3条 for (int i 0; i 3; i) { g.setColor(new Color(180 (int)(Math.random() * 70), 180 (int)(Math.random() * 70), 180)); g.drawLine((int)(Math.random() * width), (int)(Math.random() * height), (int)(Math.random() * width), (int)(Math.random() * height)); } // 绘制字符每个字符用随机颜色、随机旋转 for (int i 0; i code.length(); i) { g.setColor(new Color(30 (int)(Math.random() * 100), 30 (int)(Math.random() * 100), 30 (int)(Math.random() * 100))); g.setFont(new Font(SansSerif, Font.BOLD, 32)); g.drawString(String.valueOf(code.charAt(i)), i * 20 8, 28); }上面这块代码里最容易忽略的细节是字符留白。如果字画得太满或者字间距不均匀用户肉眼看不清体验直接崩。我当时调了好几次画布尺寸最终用了120*40找到字体大小和间距的平衡点。第二步是存储答案。生成图片的同时把验证码答案存到Rediskey用UUIDvalue是验证码内容设置5分钟过期。用户登录时必须传两个东西验证码UUID和用户输入的验证码内容这样才能对应上。第三步是返回给前端接口。后端把图片转成Base64字符串和UUID一起返回给前端// 用ImageIO把内存中的图片写为PNG ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(image, png, baos); String base64Img Base64.getEncoder().encodeToString(baos.toByteArray()); // 响应体结构 return new Result(new CaptchaVO(uuid, data:image/png;base64, base64Img));3.2 前端如何展示和刷新验证码前端这块很直接因为验证码就是一张Base64的图片用Vue的绑定就完事template div classcaptcha-wrapper img :srccaptchaImg clickrefreshCaptcha title点击刷新 alt验证码 / /div /template script export default { data() { return { captchaUuid: , captchaImg: } }, methods: { async refreshCaptcha() { const res await this.$axios.get(/api/auth/captcha) this.captchaUuid res.data.uuid this.captchaImg res.data.img } } } /script关于点击刷新这个细节我想多说一句。很多用户第一次看验证码图片没看清或者输错了最自然的操作就是点一下图片刷新。这个交互成本比“找刷新按钮”低得多一定要做上。另一个容易漏的细节是用户输入验证码错误后前端一定要自动刷新验证码。如果不刷新用户这次输错了下次看到的还是同一个验证码等于把“重试一次”的窗口无限放大暴力破解的成本就下来了——不对是上去了。但更重要的问题在后端验证码校验通过后必须立刻删除防止同一个验证码反复使用。3.3 登录接口串起整个链路登录接口是验证码和JWT交汇的地方。逻辑顺序很关键先验验证码再验密码。PostMapping(/login) public Result login(RequestBody LoginRequest request) { // 1. 先校验验证码不通过直接返回不查数据库 String savedCode redisTemplate.opsForValue().get(captcha: request.getUuid()); if (savedCode null || !savedCode.equalsIgnoreCase(request.getCode())) { return Result.error(验证码错误或已过期); } // 验证码一次性用完即焚 redisTemplate.delete(captcha: request.getUuid()); // 2. 再校验用户名密码 User user userMapper.selectByUsername(request.getUsername()); if (user null || !BCrypt.checkpw(request.getPassword(), user.getPassword())) { return Result.error(用户名或密码错误); } // 3. 签发Token String accessToken jwtUtil.generateAccessToken(user.getId(), user.getUsername()); String refreshToken jwtUtil.generateRefreshToken(user.getId()); redisTemplate.opsForValue().set(refresh: user.getId(), refreshToken, 7, TimeUnit.DAYS); return Result.success(new TokenVO(accessToken, refreshToken)); }注意这里有个性能层面的小技巧验证码校验失败时直接返回完全不用查数据库把大部分恶意请求挡在了最外层。如果你把密码校验放在验证码前面数据库就会被无效请求打穿这是我在压测时发现的。4. 前端Vuex登录态管理与实现剖析4.1 为什么用Vuex而不是直接操作localStorage这个问题我在设计初期纠结了很久。理论上登录态管理就是“存Token 取Token”localStorage加一个工具函数完全够用。但真做过项目就会发现登录态一旦复杂起来localStorage方案会让组件间通信变得非常别扭。举一个具体场景用户登录成功后顶栏要显示用户名侧边栏要刷新菜单权限页面路由要动态添加。如果我直接用localStorage存TokenToken是存进去了但怎么通知所有组件“登录成功了”你得在登录成功的函数里手动调用各个组件的更新方法耦合度极高。Vuex的答案很优雅登录成功后commit一个mutation把用户信息放进store所有组件通过mapGetters或mapState读取用户信息状态一变界面自动更新。这就是单向数据流的好处。另一个容易被忽略的点是Vuex帮我们把“登录中”这个状态也管理了起来。登录请求发出到响应回来的这段时间按钮要显示loading、防止重复提交这个isLoginLoading状态放在全局store里比放在组件data里靠谱得多。4.2 Vuex Store结构设计我最终采用的store结构如下// store/index.js import Vue from vue import Vuex from vuex import createPersistedState from vuex-persistedstate import auth from ./modules/auth Vue.use(Vuex) export default new Vuex.Store({ modules: { auth }, plugins: [ createPersistedState({ key: exam-app, paths: [auth.token, auth.refreshToken] }) ] })// store/modules/auth.js export default { namespaced: true, state: () ({ token: , refreshToken: , userInfo: null, isLoginLoading: false }), getters: { isLoggedIn: state !!state.token, username: state state.userInfo ? state.userInfo.username : }, mutations: { SET_TOKEN(state, token) { state.token token }, SET_REFRESH_TOKEN(state, token) { state.refreshToken token }, SET_USER_INFO(state, userInfo) { state.userInfo userInfo }, SET_LOGIN_LOADING(state, loading) { state.isLoginLoading loading }, CLEAR_AUTH(state) { state.token state.refreshToken state.userInfo null } }, actions: { async login({ commit }, payload) { commit(SET_LOGIN_LOADING, true) try { const res await axios.post(/api/auth/login, payload) commit(SET_TOKEN, res.data.accessToken) commit(SET_REFRESH_TOKEN, res.data.refreshToken) // 登录成功后立刻拉取用户信息 const userRes await axios.get(/api/user/info) commit(SET_USER_INFO, userRes.data) return { success: true } } catch (e) { return { success: false, message: e.message } } finally { commit(SET_LOGIN_LOADING, false) } }, async logout({ commit }) { try { await axios.post(/api/auth/logout) } finally { commit(CLEAR_AUTH) } } } }这个结构有几个值得注意的设计决策。第一为什么要持久化到localStorageVuex的state是内存态刷新页面就没了。不持久化的话用户刷新一下就被踢出登录体验非常糟糕。vuex-persistedstate插件按配置把指定path同步到localStorage页面加载时自动恢复。第二为什么只持久化token和refreshToken不持久化userInfo因为userInfo是“用户资料”可能被用户修改比如改头像、改昵称localStorage里存的可能是旧数据。更好的做法是页面刷新后用token去调一次/api/user/info拿最新数据。token是凭证userInfo是可变的业务数据两者的持久化策略应该不同。第三登录时只存token等Token拿到手后再去请求用户信息。一开始我图省事把用户名塞在JWT的Payload里登录后直接解码Payload拿用户名。后来发现用户改昵称后Token里还是旧昵称除非重新登录否则永远显示不对。改成“Token只负责认证用户信息一律通过接口获取”之后这个问题就根治了。4.3 Axios拦截器如何无缝接入Token光有store还不够每个请求都要自动带上Token这是Axios拦截器干的活。我的实现分两层请求拦截器加Token响应拦截器处理401。// utils/request.js import axios from axios import store from /store import router from /router const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) // 是否正在刷新token的标志 let isRefreshing false // 刷新token期间暂存的请求队列 let pendingQueue [] service.interceptors.request.use(config { const token store.getters[auth/token] // 注意getter写法 if (token) { config.headers[Authorization] Bearer ${token} } return config }, error { return Promise.reject(error) }) service.interceptors.response.use( response { // 后端返回结构{ code: 200, data: ..., message: ... } const res response.data if (res.code ! 200) { return Promise.reject(new Error(res.message)) } return res.data }, async error { const { response } error if (response response.status 401) { // 尝试用refreshToken换新的accessToken const refreshToken store.getters[auth/refreshToken] if (!refreshToken) { // 连refreshToken都没有直接踢回登录页 store.commit(auth/CLEAR_AUTH) router.push(/login) return Promise.reject(error) } if (!isRefreshing) { isRefreshing true try { const res await axios.post(/api/auth/refresh, { refreshToken }) store.commit(auth/SET_TOKEN, res.data.accessToken) store.commit(auth/SET_REFRESH_TOKEN, res.data.refreshToken) // 重放暂存的请求 pendingQueue.forEach(cb cb(res.data.accessToken)) pendingQueue [] return service(error.config) // 重试当前请求 } catch (refreshError) { // 刷新失败回到登录页 store.commit(auth/CLEAR_AUTH) router.push(/login) return Promise.reject(refreshError) } finally { isRefreshing false } } else { // 正在刷新中把请求放进队列 return new Promise(resolve { pendingQueue.push(newToken { error.config.headers[Authorization] Bearer ${newToken} resolve(service(error.config)) }) }) } } return Promise.reject(error) } )上面这段代码里最核心也最容易被忽略的是请求重放队列。假设用户打开页面后同时发出5个请求Token恰好过期了5个请求全部返回401。如果没有队列机制5个请求各自去调刷新接口后端收到5个几乎同时的刷新请求其中4个会失败RefreshToken已被第一个请求轮换或重放拦截。有了队列只有第一个401触发刷新其他4个排队等新Token到位后重放。这里还有一个坑刷新接口本身不能被401拦截器拦截否则会死循环。我的处理办法是刷新请求直接用裸axios.post不走service实例。4.4 路由守卫与登录态联动最后一步路由守卫。这一步解决的问题是用户没登录就访问需要登录的页面直接踢回登录页。// router/index.js router.beforeEach(async (to, from, next) { const isLoggedIn store.getters[auth/isLoggedIn] if (to.meta.requiresAuth) { if (!isLoggedIn) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } } else { // 已登录还访问登录页直接回首页 if (to.path /login isLoggedIn) { next(/) } else { next() } } })这里有个细节值得提一下登录成功后要跳回用户原本想访问的页面。所以登录页在拦截跳转时要把目标路由放到query里/login?redirect/profile登录成功的action里读一下这个参数再决定跳哪。这个小体验不做其实不致命但做了会显得很专业。5. 常见问题与排查技巧实录5.1 验证码错一次就失效用户吐槽体验差这个问题在我上线初期被反馈得最狠。我原本的设计是“验证码校验失败即删除”理论上一个验证码只能用一次安全性拉满但用户的真实行为是输错一次重新输之前先在脑子里回忆一下自己刚才到底看的是什么字然后换一个验证码——结果他刚刚看清的那个数字代码已经没法用了。后面我调整了策略验证码校验失败时不立即删除而是允许同一个验证码在5分钟有效期内尝试3次3次都失败才作废。同时每次失败都让前端自动刷新验证码。这里其实有一个平衡点允许重试太多次暴力破解成本就降下去了允许太少的重试用户体验又不行。3次是我实测下来比较平衡的数字。前端配合自动刷新即使用户没看清点了刷新也就一秒钟的事。5.2 部署到服务器后验证码图片不显示这个坑我印象太深刻了本地开发一切正常部署到服务器后验证码图片就加载不出来。排查了半天最后发现问题出在Java的字体库上。服务器是精简版Linux镜像没装中文字体。new Font(SansSerif, Font.BOLD, 32)在开发机上有字体可渲染到了服务器上直接用了缺省字体甚至可能导致图形渲染异常。解决办法是在服务器上安装字体包# Ubuntu/Debian apt-get install -y fonts-dejavu-core然后代码里指定具体字体名g.setFont(new Font(DejaVu Sans, Font.BOLD, 30));提示如果你部署到Docker容器里一定要在Dockerfile里加字体安装步骤。这种问题和业务代码无关纯粹是环境差异属于那种“线上事故级”的隐藏坑排查起来非常花时间。5.3 Vuex刷新后登录态丢失这个是Vuex新手必踩的坑。原因我在4.2里已经说了Vuex的state只存在内存里。解决方式是引入vuex-persistedstate做持久化。但持久化之后又有一个新坑用户手动改localStorage里的token或者在浏览器控制台执行store.commit(auth/SET_TOKEN, xxxx)前端就认为自己是登录状态了。这种“伪登录”其实是伪造了前端状态但后端校验不通过请求还是会401。所以不要把前端的“isLoggedIn”当作安全边界它只是改善体验的。真正的安全校验永远在后端。前端顶多做到访问需要登录的接口时如果401了不管前端状态是什么一律清空并跳回登录页。5.4 多标签页同时操作Token刷新互相踢下线这个问题的场景是用户在两个标签页打开同一个系统两边同时操作A页面的Token过期去刷新了RefreshTokenB页面的RefreshToken还是旧的刷新时后端比对Redis发现不一致判定为重放把两个标签页都踢下线了。这个场景处理方式比较粗暴但也实用如果两个标签页同时操作同一个账号后刷新的会把先刷新的踢下线这个行为是可以接受的。现实使用中一个用户在两三个标签页同时高频操作的概率不高真遇到了重新登录一遍的成本也不高。如果确实要做更优雅的方案是让同一个浏览器下的多个标签页共享同一个Storage事件监听localStorage变化时同步刷新页面里的Token。但这个方案的复杂度会上升不少而我评估这个场景的收益不值得就放弃了。这里面的取舍逻辑是不要为了极端场景把主流程搞复杂。5.5 JWT伪造与密钥泄露的防御前面提到了JWT的Signature本来就是为了防伪造。但有一个真实存在的攻击面是后端代码仓库泄露了密钥或者密钥写死在发给前端的静态资源里。我在项目里做了这几件事密钥放在环境变量里每个环境dev/test/prod各一份定期轮换密钥轮换时Redis里存一个旧的密钥列表让旧的Token在轮换过渡期还能通过校验不把任何密钥相关的东西写进前端代码另外还有一个容易忽视的点JWT的Payload是Base64编码的不是加密的。虽然它不会把用户密码带进去但如果你在Payload里放了手机号、邮箱这些用户个人信息别人只要拿到Token就能解码看到。所以我在Payload里只放userId和username其他敏感信息一律不放。6. 这套方案的后续扩展方向做完整个登录认证流程后我明显感觉对SPA项目的理解上了一个台阶。后面如果继续迭代有几个方向是我想深入的一是集成OAuth2.0支持第三方登录Github、微信扫码核心是理解Authorization Code模式的回调流程以及怎么把第三方用户的身份映射到自己系统的用户体系上。二是做更丰富的验证码方案接入滑块验证码或者点选验证码。行为式验证码的防机器能力比图形验证码强很多主要是通过收集用户的鼠标轨迹、停留时长等行为特征来判断是人还是机器。三是把权限模型做厚。现在只是“登录/未登录”二态后面可以引入RBAC基于角色的权限控制在JWT里加入角色信息前端通过Vuex管理权限路由动态生成菜单和路由表。我在实际开发中最大的体会是登录认证这套东西看文档觉得不难真正动手做才会发现难点全在边界情况和异常路径上——并发刷新、重放攻击、多标签页、XSS防护、过期时间设计、刷新轮换策略每一处都是坑。把这些坑都趟平一遍你对“前端状态管理”和“后端无状态认证”这两件事的理解比看十篇面试八股文都有用。