2026/10/30 8:59:45

Spring Boot 3 + Vue 3 JWT登录全链路实战

Spring Boot 3 + Vue 3 JWT登录全链路实战 简介面向Java全栈初学者与前后端分离项目练手者的JWT登录示例工程围绕Spring Boot 3与Vue 3搭建一套可参考的用户认证流程后端承担注册登录业务、令牌签发与校验前端负责表单交互、请求携带令牌及路由访问控制可用于课程设计原型或企业项目脚手架借鉴也适合作为理解无状态认证的入门练习。压缩包共66个文件、约93KB含13个java源码及对应class编译产物、10个xml配置、5个vue单文件组件、7个ts脚本与若干json、yml及样式资源后端admin与前端web分目录组织层次清晰。目前已有100人学习。代码按controller、service与前端router、store、utils、view分层可对照理解请求拦截、令牌存储与接口对接细节也可作为排查跨域、请求头丢失等问题的参考便于快速跑通并二次改造。1. 一份能跑通的登录 Demo比十篇 Security 教程都管用有个读者拿一份 Spring Boot 3 Vue 3 的 JWT 登录 Demo 来问接口用 Postman 能通前端一刷新页面就退回登录页。查了半天问题出在 Pinia 里的 token 只存在内存刷新就没了。这类毛病在 Spring Security 6 之后特别密集配置类换了写法、JJWT 换了 API、Vue 3 又换了状态管理习惯三处同时变“能跑起来”和“知道为什么能跑”之间隔着一整条链路。这份 Demo 的价值就在这儿。它把一条 token 从登录框签发出去、经 axios 拦截器、穿过 Security 过滤器链、再由路由守卫决定放不放行整条链路串成一条能打断点、能看日志的线。适合刚接手 Spring Boot 3 项目的后端也适合一个人要从前端写到数据库的全栈同学。2. 选型与骨架为什么无状态 token 更适合前后端分离2.1 不选 Session 的理由以及 JWT 要付的代价把登录态放服务端 Session浏览器自动带 Cookie写起来最省事。但前后端分离之后这套做法开始别扭前端跑在 5173后端跑在 8080属于跨源多端浏览器、移动端、小程序要共用一套接口Cookie 并不是每个端都天然有后端一旦扩到多实例会话粘滞或者共享存储就得额外维护一份。换成无状态 token服务端不存任何会话每个请求自带凭证验签通过就放行横向扩节点不用同步任何东西。代价也得说清楚否则落地时容易后悔签发出去的 token 在过期前无法单方面作废想“踢人下线”必须配上黑名单或短有效期这是最常见的认知落差。payload 只是 Base64URL 编码不是加密任何人都能解开看内容。手机号、身份证号、内部 ID 一律不要往里放。token 体积比 sessionId 大得多每个请求都要带请求头体积会涨。塞满自定义 claim 是典型误用。我一般的取舍是后台管理类系统access token 有效期给 30 分钟到 2 小时面向 C 端的压到 15 分钟以内用刷新令牌续期。这份 Demo 用的是单 token 简化版够演示链路生产上再照第 6 章补刷新逻辑。2.2 依赖与版本JJWT 0.12 的 API 已经和教程里不一样了Spring Boot 3 的硬门槛是 JDK 17这一点先确认java -version出来是 8 或 11 的机器上跑不起来报错还很不直观。另外 Spring Security 6 把WebSecurityConfigurerAdapter彻底删了网上大量 2.x 时代的配置类直接复制过来会编译不过只能改成注册SecurityFilterChainBean 的写法。JWT 库选 JJWT官方维护、API 稳定但 0.11 到 0.12 之间做了一次方法签名统一老教程里的setSubject、parseClaimsJws、signWith(key, SignatureAlgorithm.HS256)全部换名。项目里的依赖清单如下依赖版本示例作用注意点spring-boot-starter-security跟随 Boot 3.2.x过滤器链、密码编码器引入后所有接口默认要认证spring-boot-starter-web跟随 Boot 3.2.xREST 接口无特殊配置jjwt-api0.12.6编译期用到的接口必须显式写版本jjwt-impl0.12.6运行时实现scope 设 runtimejjwt-jackson0.12.6claim 的 JSON 序列化缺了会运行时报错!-- pom.xml 片段版本以你本地仓库能拉到的 0.12.x 为准 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.6/version /dependency !-- impl 与 jackson 只在运行时需要打成 runtime 可以避免误用内部类 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.6/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.6/version scoperuntime/scope /dependency参数说明jjwt-impl和jjwt-jackson设成runtime不只是洁癖impl里的类属于内部实现编译期可见容易写出依赖具体实现类的代码升级时会踩坑。三个包的版本必须完全一致混版本最典型的症状是运行期抛NoClassDefFoundError或UnsupportedJwtException编译期毫无提示。2.3 一条 token 的完整生命周期把链路摊开后面每一步的代码才有位置感前端提交用户名密码到/api/auth/login。后端按用户名查库用BCryptPasswordEncoder.matches比对密文。校验通过JwtService用密钥签发 tokenclaim 里放用户名和角色。响应体返回{ token, username, roles }前端存进 Pinia 并落一份 localStorage。后续每个请求由 axios 拦截器补上Authorization: Bearer token。后端的OncePerRequestFilter截住请求解析 token把认证对象塞进SecurityContextHolder。SecurityFilterChain依据SecurityContext是否已认证决定放行或 401。前端响应拦截器统一处理 401清状态、跳登录。注意第 6 步认证对象必须放进当前请求线程的SecurityContext。Spring Security 6 默认策略下上下文不会自动跨线程传递一旦用了异步 Servlet 或自己开了线程池父线程塞进去的认证信息在子线程里读不到表现就是“明明登录了却一直 401”这是排查时最容易绕晕的一环。3. 后端落地过滤器、密钥与 SecurityFilterChain3.1 用户表与密码存储先约定再写代码密码绝不能明文存也不能用 MD5 加盐凑合。BCrypt 自带随机盐同一密码每次编码结果都不同比对时用matches而不是字符串相等。表结构建议最少这几个字段id、username唯一索引、password_hash60 位左右给足长度、roles逗号分隔或单独关联表、enabled。Configuration public class PasswordConfig { // 强度 10 是默认值约 100ms 一次哈希调到 12 会明显拖慢登录接口 Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(10); } }参数说明构造函数里的数字是 cost每加 1 计算量翻倍。压测环境下如果登录接口 RT 上不去先看这里是不是被人调到 14 以上了。另外库里已有的老数据如果是 MD5需要写一次性迁移逻辑在用户下次登录时用明文重新编码成 BCrypt别指望直接换掉。3.2 JwtService签发与解析都集中在一个类里Service public class JwtService { private final SecretKey key; private final long expireMinutes; public JwtService(Value(${app.jwt.secret}) String secret, Value(${app.jwt.expire-minutes:120}) long expireMinutes) { // HS256 要求密钥不少于 256 bit即 32 字节 this.key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.expireMinutes expireMinutes; } public String issue(String username, ListString roles) { Instant now Instant.now(); return Jwts.builder() .subject(username) // 0.11 里的 setSubject .claim(roles, roles) // 自定义 claim保持精简 .issuedAt(Date.from(now)) .expiration(Date.from(now.plusSeconds(expireMinutes * 60))) .signWith(key) // 算法由密钥长度推导无需显式指定 .compact(); } public Claims parse(String token) { return Jwts.parser() .verifyWith(key) // 0.11 里的 setSigningKey .build() .parseSignedClaims(token) // 0.11 里的 parseClaimsJws .getPayload(); } }逻辑说明issue把过期时间算成绝对时间戳写进expparse在校验签名的同时会顺带校验exp过期直接抛ExpiredJwtException不需要自己判断。参数说明expire-minutes从配置读别硬编码在类里测试环境和生产环境的值通常不一样roles放字符串列表就够了别把整个用户对象序列化进 claim那会让 token 长到几 KB。3.3 JwtAuthenticationFilter一次请求只解析一次Component RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header request.getHeader(Authorization); // 没有凭证也继续往下走交给后续的授权规则决定是否 401 if (header ! null header.startsWith(Bearer )) { String token header.substring(7).trim(); try { Claims claims jwtService.parse(token); ListString roles claims.get(roles, List.class); var authorities roles null ? List.GrantedAuthorityof() : roles.stream().map(r - new SimpleGrantedAuthority(ROLE_ r)).toList(); var auth new UsernamePasswordAuthenticationToken( claims.getSubject(), null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); } catch (JwtException | IllegalArgumentException e) { // 令牌非法或过期清空上下文不要在这里直接写响应 SecurityContextHolder.clearContext(); } } chain.doFilter(request, response); } }逻辑说明这里是全篇最容易被写错的地方。第一不要在这个过滤器里直接response.setStatus(401)然后return那样SecurityFilterChain的异常处理配置全部失效前端拿到的错误体格式和其他接口对不上。第二认证成功后必须显式setAuthentication只解析不塞上下文等于白做。第三捕获异常后要clearContext否则同一线程复用时可能残留上一个请求的身份。参数说明OncePerRequestFilter保证一次请求只走一遍内部转发不会重复执行ROLE_前缀是hasRole的约定如果你配置里用hasAuthority(admin)就不要加前缀两者混用会出现“角色明明对但一直 403”的情况。3.4 SecurityFilterChain把 401 和 403 的响应体统一掉Configuration EnableWebSecurity RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtFilter; private final ObjectMapper objectMapper; Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) // 无状态 不用 Cookie可关 .cors(Customizer.withDefaults()) // 交给 CorsConfigurationSource .sessionManagement(s - s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login).permitAll() .requestMatchers(HttpMethod.OPTIONS, /**).permitAll() // 放行预检 .anyRequest().authenticated()) .exceptionHandling(e - e .authenticationEntryPoint((req, res, ex) - write(res, 401, 未认证或凭证已失效)) .accessDeniedHandler((req, res, ex) - write(res, 403, 权限不足))) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } private void write(HttpServletResponse res, int code, String msg) throws IOException { res.setStatus(code); res.setContentType(application/json;charsetUTF-8); res.getWriter().write(objectMapper.writeValueAsString( Map.of(code, code, message, msg))); } }逻辑说明authenticationEntryPoint管“没登录”accessDeniedHandler管“登录了但没权限”两个都自定义之后前端拦截器才拿得到稳定的 JSON 结构不用靠状态码猜。参数说明.csrf().disable()在前端不用 Cookie 传凭证的场景下是安全的如果哪天改成 Cookie 存 token这一行必须删掉否则就是典型的 CSRF 漏洞。STATELESS不是可选项它决定了 Security 不会创建 HttpSession也会影响上下文持久化策略。4. 前端落地Pinia、拦截器与路由守卫三件套4.1 Store 里做持久化别只放在内存// stores/user.js import { defineStore } from pinia import { ref, computed } from vue export const useUserStore defineStore(user, () { // 初始化直接从 localStorage 取刷新后状态不会丢 const token ref(localStorage.getItem(token) || ) const profile ref(JSON.parse(localStorage.getItem(profile) || null)) const isLoggedIn computed(() !!token.value) function setAuth(data) { token.value data.token profile.value { username: data.username, roles: data.roles } localStorage.setItem(token, token.value) localStorage.setItem(profile, JSON.stringify(profile.value)) } function clear() { token.value profile.value null localStorage.removeItem(token) localStorage.removeItem(profile) } return { token, profile, isLoggedIn, setAuth, clear } })逻辑说明关键在于ref的初值从 localStorage 读而不是空字符串。这样刷新页面时store 一创建就带着 token路由守卫判断isLoggedIn才成立。参数说明profile存的是展示用的用户名和角色别拿它做权限判断真正的权限永远以后端返回为准前端判断只是减少无效请求。安全上localStorage 会受 XSS 影响如果项目对这块要求高改成 httpOnly Cookie 存 token同时把 3.4 的 csrf 配置打开。4.2 axios 拦截器统一带票与统一处理 401// utils/request.js import axios from axios import { useUserStore } from /stores/user import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use((config) { const store useUserStore() if (store.token) { config.headers.Authorization Bearer ${store.token} } return config }) let redirecting false // 防止多个并发 401 触发多次跳转 request.interceptors.response.use( (res) res.data, (error) { const status error.response?.status if (status 401 !redirecting) { redirecting true const store useUserStore() store.clear() router.replace({ name: login, query: { redirect: router.currentRoute.value.fullPath } }).finally(() { redirecting false }) } return Promise.reject(error) } ) export default request逻辑说明请求拦截器负责带票响应拦截器负责处理失效。redirecting这个标志位不是可选的页面初始化时常常并发好几个请求token 一过期就是同时三四个 401不加锁会连续跳转好几次路由报NavigationDuplicated或者页面闪。参数说明baseURL: /api配合 Vite 的 proxy 使用生产环境换成 nginx 反代前端代码一行不用改timeout设 10 秒够用别设成 0。4.3 路由守卫与刷新后的状态恢复// router/index.js import { createRouter, createWebHistory } from vue-router import { useUserStore } from /stores/user const router createRouter({ history: createWebHistory(), routes: [ { path: /login, name: login, component: () import(/views/Login.vue) }, { path: /, name: home, component: () import(/views/Home.vue), meta: { requiresAuth: true } } ] }) router.beforeEach((to) { const store useUserStore() if (to.meta.requiresAuth !store.isLoggedIn) { return { name: login, query: { redirect: to.fullPath } } } if (to.name login store.isLoggedIn) { return { name: home } } }) export default router逻辑说明守卫里只做“有没有票”的判断不做“票有没有过期”的判断——过期判断交给后端前端去解析exp属于重复实现且容易和服务器时间不同步。参数说明redirect查询参数在登录成功后用router.replace(query.redirect || /)用回去比固定跳首页体验好。注意在守卫里调用useUserStore()必须在createPinia()之后否则拿不到实例报错信息还很难懂。4.4 Vite proxy跨域的临门一脚// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true // 后端接口本身就带 /api 前缀所以不要 rewrite } } } })逻辑说明开发期走 proxy浏览器看到的是同源请求不会触发预检。参数说明changeOrigin会把 Host 头改成目标地址后端如果做了 Host 校验就必须开rewrite只在后端路径不带前缀时用配错了的表现是接口 404 但后端日志一条没有因为请求根本没打过去。生产环境换成 nginx 的location /api/ { proxy_pass http://backend; }同时记得把Authorization透传默认是透传的。5. 踩坑排查五个真实翻车点的定位顺序5.1 先看日志还是先看请求头我的固定顺序是先开浏览器 Network 看请求有没有带Authorization再看后端日志有没有进过滤器最后才怀疑签名和密钥。90% 的 401 出在前两步——要么前端没带票store 没初始化、axios 实例被另一个没配拦截器的实例替换了要么过滤器压根没执行没加Component或者addFilterBefore挂错了位置。密钥问题的特征很明确日志里一定有SignatureException堆栈不会静默失败。5.2 坑一登录接口 200下一个请求立刻 401现象登录成功返回体里 token 也有但紧接着调/api/user/me就是 401后端日志没有任何异常。原因多半是过滤器里认证成功后没有真正写进上下文或者写了但SecurityContextHolder的策略是MODE_THREADLOCAL且请求走了异步链路。还有一种常见情况是UsernamePasswordAuthenticationToken用了单参构造构造出来的对象isAuthenticated()是 falseauthorizeHttpRequests照样判未认证。解决用三参构造new UsernamePasswordAuthenticationToken(principal, null, authorities)在过滤器chain.doFilter之前打一行日志确认SecurityContextHolder.getContext().getAuthentication()非空如果项目里用了Async或 WebFlux改用DelegatingSecurityContextExecutor包装线程池。5.3 坑二刷新页面就被踢回登录页现象登录后正常使用按 F5 立刻跳登录token 明明还在 localStorage。原因Pinia store 定义时token ref()没有从 localStorage 读初值。页面刷新内存清空isLoggedIn变 false守卫直接拦下。另一种变体是路由守卫写在了createPinia()之前执行useUserStore()抛错被吞掉。解决store 初始化时读 localStorage见 4.1确保app.use(pinia)在app.use(router)之前router 实例创建时不要立即调用 store放到beforeEach里调用。另外如果有人把 token 只存 Vuex/Pinia 不落盘多标签页之间也同步不了这是业务上容易忽略的点。5.4 坑三CORS 预检 OPTIONS 被拦成 401现象前端报Response to preflight request doesnt pass access control check后端日志显示 OPTIONS 请求返回 401。原因预检请求不带Authorization头过滤器解析不到凭证anyRequest().authenticated()直接把它拒了。这类问题在本地用 proxy 时压根不出现一部署到跨域环境就爆。解决在authorizeHttpRequests里显式放行HttpMethod.OPTIONS, /**见 3.4配置CorsConfigurationSource把allowedOrigins写成具体域名而不是*带凭证的请求不允许通配检查响应头里Access-Control-Allow-Origin是否出现了两次那说明 Spring Security 和 nginx 各加了一遍删掉一处。5.5 坑四启动就抛 WeakKeyException现象应用启动或第一次签发 token 时抛io.jsonwebtoken.security.WeakKeyException: The signing keys size is 128 bits。原因app.jwt.secret配的字符串太短。HS256 要求密钥长度不低于 256 bit也就是 32 个字节中文字符按 UTF-8 占 3 字节凭感觉写个“secret123”远远不够。解决换成 32 字节以上的随机串用openssl rand -base64 48生成后放进配置中心或环境变量不要提交到仓库.gitignore里加一条。多环境用不同密钥避免测试环境的 token 拿到生产还能验签通过。5.6 坑五token 过期后前端疯狂重定向现象token 过期页面在登录页和首页之间反复跳控制台刷屏NavigationDuplicated后端被一串请求打满。原因响应拦截器里 401 就router.push(/login)而登录页初始化时又发了几个需要认证的请求又是 401又跳形成循环。解决加redirecting标志位见 4.2登录页的初始化请求全部改成不需要认证的公开接口更稳的做法是拦截器里判断当前路由已经是 login 就直接 reject 不再跳转。6. 从 Demo 到能上线双 token 与上线前自测6.1 补一层刷新令牌把过期体验做顺单 token 最大的问题是“要么太短频繁登录要么太长无法回收”。常见做法是双 tokenaccess token 15 分钟refresh token 7 天且存库。access 过期时前端用 refresh 换新换失败才跳登录。后端加一个/api/auth/refresh接口校验 refresh token 的签名和库中记录是否一致一致则签发新的 access 并轮换 refresh旧的立即作废。这样即便 refresh 泄露也只能用一次。// 刷新时的核心判断注意要校验库中状态而不是只验签 public String refresh(String refreshToken) { Claims claims jwtService.parse(refreshToken); // 验签 过期校验 String jti claims.getId(); if (!refreshStore.isActive(jti)) { throw new IllegalStateException(刷新令牌已失效); // 已轮换或被踢下线 } refreshStore.revoke(jti); // 旧的立刻作废 return jwtService.issue(claims.getSubject(), claims.get(roles, List.class)); }6.2 上线前把这几项过一遍检查项合格标准常见不合格表现密钥管理32 字节以上走环境变量硬编码在 yml 并提交仓库token 有效期access ≤ 2 小时图省事设成 7 天敏感信息claim 里无手机号、身份证直接序列化整个用户对象密码存储BCryptcost ≥ 10MD5 加固定盐401/403 响应统一 JSON 结构直接返回 HTML 错误页登出前端清状态 服务端失效 refresh只清前端token 仍可用6.3 我自己的验证习惯每次改完认证相关代码我会固定跑三条断言拿过期 token 请求受保护接口必须返回 401 且响应体是 JSON用篡改过 payload 的 token 请求必须 401 且日志有签名异常不带 token 请求公开接口必须 200。这三条能覆盖绝大多数回归。还有一点血泪经验任何一次修改SecurityFilterChain之后都不要只测登录接口公开接口和受保护接口各挑一个一起测过滤器顺序调错时往往只坏一半。从那以后我每次动安全配置都会把这三条断言先跑一遍再提交比事后翻日志省事太多。希望帮到你。本文还有配套的精品资源点击获取