简介:面向前后端分离开发者的 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 做了用户状态的集中管理。相比 Vuex,Pinia 更轻量,而且它对 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拼接后的完整路径与后端匹配。我排查时一般是先把安全配置里所有鉴权临时放开,确认接口本身能通,再一层层加回鉴权规则。
坑 2:Token 解析一直报 SignatureException现象是登录成功后,拿到的 Token 在后续请求中验签失败。原因一般是前端把 Token 存进了 Cookie 导致编码变了,或者后端密钥配置在多个环境间不一致,或者代码里对 Token 做了 URL 解码而 Signature 里的特殊字符被破坏了。解决方法是统一用 localStorage 管理 Token,密钥用统一配置中心或环境变量注入。如果你把密钥写死在代码里,换环境就要改代码重新打包,非常忌讳。
坑 3:Token 过期时间没生效现象是配置文件里写的expiration: 7200,但 Token 始终不过期。原因多半是配置类没用@ConfigurationProperties正确绑定,jwtProperties.getExpiration()拿到的值是 null,底层用了默认值。解决方法是给配置类加上@ConfigurationProperties(prefix = "jwt"),并在启动日志里输出当前生效的过期时间,确认实际加载的值。这一步属于「见光死」排查法,把关键配置打印出来,问题立刻现行。
坑 4:401 响应体不是 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。这套思路你在自己公司的项目里也可以直接迁移,后端只要预留一个刷新接口即可。希望帮到你。
本文还有配套的精品资源,点击获取