简介面向前后端分离开发者的 Spring Boot 3 Vue 3 JWT 登录示例围绕 JWT 无状态认证机制完整演示用户注册、登录、令牌生成与校验、受保护接口访问等核心流程项目包含可直接运行的 Demo 源码。资源共 66 个文件压缩包约 93KB整体分为 job-admin 后端与 job-web 前端两大模块后端以 13 个 Java 文件为主辅以 XML、YAML 配置涵盖 Controller、Service、Security 配置及 Maven 工程结构前端以 Vue、TypeScript、CSS/SCSS 为主覆盖页面、路由、状态管理和 Vite 配置另有 JSON、Markdown、Git 忽略文件等辅助内容目录条理清晰能够方便地定位到登录相关代码。已有 100 人学习下载。通过该示例可以系统理解前后端分离下的 JWT 认证交互流程包括登录成功后 Token 如何生成与返回、前端如何安全存储并在请求头中携带、后端如何通过拦截器校验用户身份并避开常见的跨域与 Token 有效期问题同时还能体会 Spring Boot 3 约定优于配置的工程组织方式以及 Vue 3 Composition API 对逻辑复用的改进。对需要快速搭建 JWT 登录 Demo、入门前后端分离开发的中级工程师是一份便于直接启动和二次扩展的实战参考。1. SpringBoot3 Vue3 的 JWT 登录 Demo前后端分离认证的样板间如果你正被「前后端分离后登录状态怎么维持」这个问题卡住这份 SpringBoot3 Vue3 的 JWT 登录 Demo 就是为你准备的。它是一套可以直接跑起来的完整工程后端用 SpringBoot3 封装了 JWT 令牌的生成、校验、刷新链路前端用 Vue3 配合 Pinia 管理用户状态并把 token 注入到 Axios 拦截器里。做毕业设计、接外包项目或者公司内部管理系统想快速搭认证骨架这套东西能帮你省掉从零趟坑的大把时间。我拆完这份源码最大的感受是它把 JWT 最核心的「无状态认证 过期刷新」这套机制落到了可运行的代码上而不是停留在理论。读完你会发现登录逻辑并没有想象中那么玄学坑基本都集中在配置和边界处理上。2. 拆解认证链路JWT 无状态方案为什么能扛住前后端分离2.1 从 Session 到 JWT一次选型背后的真实理由在前后端分离的项目里传统 Session 方案天然不讨喜。Session 依赖服务端存储用户一多Session 同步就变成运维噩梦跨域场景下 Cookie 的 SameSite 属性又经常把前端折腾得够呛。而 JWT 的方案是把用户身份信息加密签名为一串 Token服务端不存任何登录态每次请求带上 Token后端验签通过就认你这个请求。这套 Demo 里的 JWT 实现用的密钥是 HS256 对称加密算法服务端生成 Token再拿同一个密钥去校验。对称加密虽然不像 RSA 那样能实现「只验签不解密」的职责分离但对单体应用或者中小型内部系统来说开发成本低、性能开销小绝大多数场景都够用。我没记错的话这个 Demo 的 Token 载荷里放了用户 ID、用户名、过期时间这几个核心字段没把密码之类的敏感信息塞进去这个习惯很关键。很多新手写 JWT 时恨不得把整个用户实体都序列化进 Token一旦密钥泄露等于把用户表脱库给别人看。2.2 Token 怎么生成和校验核心代码逐行拆后端 JWT 的核心逻辑一般集中在工具类和过滤器里。这套代码里生成 Token 的方法大致长这样public String generateToken(UserVO user) { Date now new Date(); Date expiryDate new Date(now.getTime() jwtProperties.getExpiration()); return Jwts.builder() .setSubject(user.getUsername()) .claim(userId, user.getId()) .claim(role, user.getRole()) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecret()) .compact(); }这段代码里setSubject存的是用户名claim放的是自定义业务字段setExpiration控制 Token 的有效期。signWith用配置里的密钥做签名最终 compact 成一段不可篡改的 Token 字符串。参数设置上有个值得说的点有效期的配置是从application.yml里读取的不是写死在代码里。这属于好习惯不同环境开发、测试、生产可以配不同的过期时间。我一般建议项目里预留两个时间参数——Token 过期时间和刷新过期时间后面会解释为什么。校验这边过滤器里拿到的逻辑是try { Claims claims Jwts.parser() .setSigningKey(jwtProperties.getSecret()) .parseClaimsJws(token) .getBody(); String username claims.getSubject(); Long userId claims.get(userId, Long.class); // 将解析出的用户信息放入 SecurityContext UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(username, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (JwtException | IllegalArgumentException e) { // 记录日志不直接抛出交给全局异常处理 }注意这里解析失败后没有立刻返回 401而是把异常抛给上层由全局异常处理器统一转成 JSON 返回。这个设计能保证过滤器链不中断后面如果接了网关或者第三方登录扩展点会更多。2.3 Spring Security 过滤器链JWT 是怎么被塞进认证体系的很多第一次碰 Spring Security 的人看到过滤器链就头大。实际上这套 Demo 的思路很直接默认的 Spring Security 是基于 Session 的既然用了 JWT那就把 Session 那套绕过去。关键配置大致是http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/**, /api/public/**).permitAll() .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);STATELESS表示服务端不创建 Session这是 JWT 方案的核心配置。addFilterBefore把自定义的 JWT 过滤器插入到 Spring Security 默认过滤器之前这样请求进来时JWT 过滤器先解析 Token把用户信息塞进 SecurityContext后面的授权过滤器自然就认这个用户。踩坑点来了permitAll()的路径如果你的前端登录接口没对齐会出现「登录接口本身被拦截」的玄学问题。我第一次跑这类 Demo 时接口路径是/api/auth/login前端 axios 配置的 baseURL 是/api后面前缀没对上卡了快俩小时。3. 前端 Vue3 侧的状态管理Pinia Axios 拦截器的组合拳3.1 用户状态和 Token 存哪里Pinia 的模块化设计前端这套代码用 Pinia 做了用户状态的集中管理。相比 VuexPinia 更轻量而且它对 TypeScript 的支持让项目后期维护舒服很多。用户 Store 大致是这个结构// stores/user.js export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: {} }), getters: { isLoggedIn: (state) !!state.token }, actions: { async login(loginForm) { const res await loginApi(loginForm) this.token res.data.token this.userInfo res.data.userInfo localStorage.setItem(token, this.token) }, logout() { this.token this.userInfo {} localStorage.removeItem(token) } } })注意state初始化时直接从 localStorage 读 Token这样用户刷新页面后登录态不丢。这是前端 JWT 方案必须做的一步否则 F5 一按用户以为自己掉线了体验极其糟糕。3.2 Axios 请求拦截器把 Token 塞进请求头的正确姿势光有 Store 还不够Token 必须每次请求都带上。这个 Demo 的 Axios 封装里请求拦截器长这样service.interceptors.request.use( config { const userStore useUserStore() if (userStore.token) { config.headers[Authorization] Bearer userStore.token } return config }, error Promise.reject(error) )Bearer前缀是约定俗成的规范后端解析时也会按这个前缀去截取 Token。有些后端框架比如 Sa-Token只认不带前缀的 Token如果你做集成开发前后端这块必须对齐约定。响应拦截器是另一个重要节点service.interceptors.response.use( response response, error { if (error.response.status 401) { const userStore useUserStore() userStore.logout() router.push(/login) } return Promise.reject(error) } )这里碰到 401 时强制退出登录并跳转登录页属于基础的过期处理。但说实话直接粗暴弹出登录页的方案体验不好后面第 5 章会讲更丝滑的做法。3.3 路由守卫前端页面层面的最后一道门如果你以为后端有认证就够了前端路由守卫不加用户直接输 URL 就能绕过登录页看到页面框架虽然数据拿不到但体验依然稀碎。这套 Demo 里的路由守卫配置router.beforeEach((to, from, next) { const userStore useUserStore() if (to.path ! /login !userStore.isLoggedIn) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })这里的redirect参数是加分项。用户被拦到登录页后登录成功可以直接跳回原本想访问的页面而不是每次都要手动导航。这个细节很多开源项目都不做但这个 Demo 做了值得抄进自己的项目里。4. 避坑指南JWT 登录 Demo 最常见的五个翻车现场实践下来新手跑这套代码最容易翻车的点普遍集中在以下五个场景每个都是「现象 → 原因 → 解决」的结构照着一一排查能省下大量查资料的时间。坑 1登录接口一请求就 403现象是 POST/api/auth/login返回 403请求根本进不到 Controller。原因几乎都是 Spring Security 的 CSRF 防护没关或者permitAll()配置的路径和实际请求路径不一致。解决方法是确认http.csrf().disable()已生效并核对前端baseURL拼接后的完整路径与后端匹配。我排查时一般是先把安全配置里所有鉴权临时放开确认接口本身能通再一层层加回鉴权规则。坑 2Token 解析一直报 SignatureException现象是登录成功后拿到的 Token 在后续请求中验签失败。原因一般是前端把 Token 存进了 Cookie 导致编码变了或者后端密钥配置在多个环境间不一致或者代码里对 Token 做了 URL 解码而 Signature 里的特殊字符被破坏了。解决方法是统一用 localStorage 管理 Token密钥用统一配置中心或环境变量注入。如果你把密钥写死在代码里换环境就要改代码重新打包非常忌讳。坑 3Token 过期时间没生效现象是配置文件里写的expiration: 7200但 Token 始终不过期。原因多半是配置类没用ConfigurationProperties正确绑定jwtProperties.getExpiration()拿到的值是 null底层用了默认值。解决方法是给配置类加上ConfigurationProperties(prefix jwt)并在启动日志里输出当前生效的过期时间确认实际加载的值。这一步属于「见光死」排查法把关键配置打印出来问题立刻现行。坑 4401 响应体不是 JSON前端拦截器拿不到错误信息现象是 Token 过期后前端收到的是 Spring Security 默认的 HTML 错误页前端.json取值报错。原因是框架默认的认证入口是重定向或者返回 HTML没有走全局异常处理器。解决方法是自定义AuthenticationEntryPoint让它直接返回 JSON 格式的 401 响应体里面带上具体错误码前端好根据错误码区分 Token 缺失、过期、还是非法。坑 5并发请求时 Token 刷新导致多个请求同时失败现象是 Token 有效期设得很短比如 5 分钟某一个瞬间多个请求同时发现 Token 过期前端连续弹出多个重登提示。原因是刷新逻辑没有做「单飞」处理每个请求各刷各的。解决方法是在 Axios 里用 Promise 做队列控制第一个 401 触发刷新后续 401 等待同一个刷新 Promise 完成然后重新执行原请求。这块逻辑我在第 5 章给出可直接抄的代码。5. 把 Token 过期和刷新做成「无感续期」一份能直接抄的增强实现第 4 章里提到的坑 5值得单独拎出来好好解决。直接跳登录页的体验太粗暴用户的输入内容、页面状态全丢。更成熟的方案是「无感刷新」Token 快过期或已过期时前端自动用一个 refreshToken 去换新 Token所有进行中的请求排队等待新 Token 生成后自动重放。5.1 设计思路上为什么需要两个 Token单 Token 方案在处理过期问题上很尴尬有效期设短了用户频繁掉线设长了Token 被窃取后的风险窗口期变长。双 Token 方案把「认证」和「续期」拆开accessToken有效期 30 分钟用于每次请求的身份认证refreshToken有效期 7 天只用于调刷新接口换新 accessToken不走业务请求业务接口只校验 accessToken过期了不直接 401而是返回特定的过期标识前端拿到这个标识用 refreshToken 换新 Token然后重放原请求。refreshToken 如果也过期了才真正跳登录页。5.2 后端的刷新逻辑改造后端代码里刷新接口的核心实现PostMapping(/refresh) public Result refresh(RequestBody RefreshDTO dto) { // 校验 refreshToken 是否有效未过期且签名正确 if (jwtUtils.validateRefreshToken(dto.getRefreshToken())) { String username jwtUtils.getUsernameFromRefreshToken(dto.getRefreshToken()); User user userService.getByUsername(username); // 生成全新的 accessToken 和 refreshToken String newAccessToken jwtUtils.generateAccessToken(user); String newRefreshToken jwtUtils.generateRefreshToken(user); return Result.success(new TokenVO(newAccessToken, newRefreshToken)); } throw new BusinessException(刷新令牌已过期请重新登录); }刷新 Token 时同时把 refreshToken 也换新这个操作叫「轮换 refreshToken」。它能防止旧 refreshToken 被反复利用一旦检测到使用已轮换过的 refreshToken直接判定为异常并让该用户全部 Token 失效。5.3 前端 Axios 响应拦截器的无感刷新队列这部分是增强实现的核心直接给代码let isRefreshing false let pendingQueue [] service.interceptors.response.use( response response.data, async (error) { const { response, config } error // 排除登录接口本身避免死循环 if (response response.status 401 !config._retry !config.url.includes(/auth/login)) { // 同一个 401 请求只处理一次 config._retry true // 已有的刷新请求还在进行中就排队 if (isRefreshing) { return new Promise((resolve) { pendingQueue.push({ resolve, config }) }) } isRefreshing true try { const refreshToken localStorage.getItem(refreshToken) const res await refreshTokenApi(refreshToken) const newAccessToken res.data.accessToken localStorage.setItem(token, newAccessToken) // 重放排队的请求 pendingQueue.forEach(item { item.config.headers[Authorization] Bearer ${newAccessToken} item.resolve(service(item.config)) }) pendingQueue [] // 重放当前请求 config.headers[Authorization] Bearer ${newAccessToken} return service(config) } catch (refreshError) { // refreshToken 也失效清空本地状态并跳登录页 localStorage.removeItem(token) localStorage.removeItem(refreshToken) router.push(/login) return Promise.reject(refreshError) } finally { isRefreshing false } } return Promise.reject(error) } )核心逻辑拆开看isRefreshing是一个全局开关保证同一时刻只有一个刷新请求在飞pendingQueue保存所有并发请求的 Promise 的 resolve 方法和当前配置等新 Token 拿到后统一重放。这里有个容易忽略的细节config._retry标记是为了防止 401 被处理一次后重放又报 401导致无限循环。我一般还会在请求拦截器里加一个小优化const AHEAD_TIME 60 // 提前 60 秒刷新 const payload JSON.parse(atob(token.split(.)[1])) const exp payload.exp * 1000 - AHEAD_TIME if (Date.now() exp) { // 直接走刷新逻辑不等服务端 401 }这个「提前续期」策略能进一步降低 401 出现的概率用户的感知更无感。从那以后我每次做 JWT 登录 Demo都会强制走一遍这套「请求拦截注入 401 队列刷新」的完整链路而不是只做最基本的登录发 Token。这套思路你在自己公司的项目里也可以直接迁移后端只要预留一个刷新接口即可。希望帮到你。本文还有配套的精品资源点击获取