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

发布时间:2026/10/30 8:59:38
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 设 runtime
jjwt-jackson0.12.6claim 的 JSON 序列化缺了会运行时报错
<!-- pom.xml 片段,版本以你本地仓库能拉到的 0.12.x 为准 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.6</version> </dependency> <!-- impl 与 jackson 只在运行时需要,打成 runtime 可以避免误用内部类 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.6</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.6</version> <scope>runtime</scope> </dependency>

参数说明:jjwt-impl和jjwt-jackson设成runtime不只是洁癖,impl里的类属于内部实现,编译期可见容易写出依赖具体实现类的代码,升级时会踩坑。三个包的版本必须完全一致,混版本最典型的症状是运行期抛NoClassDefFoundError或UnsupportedJwtException,编译期毫无提示。

2.3 一条 token 的完整生命周期

把链路摊开,后面每一步的代码才有位置感:

  1. 前端提交用户名密码到/api/auth/login。
  2. 后端按用户名查库,用BCryptPasswordEncoder.matches比对密文。
  3. 校验通过,JwtService用密钥签发 token,claim 里放用户名和角色。
  4. 响应体返回{ token, username, roles },前端存进 Pinia 并落一份 localStorage。
  5. 后续每个请求由 axios 拦截器补上Authorization: Bearer <token>。
  6. 后端的OncePerRequestFilter截住请求,解析 token,把认证对象塞进SecurityContextHolder。
  7. SecurityFilterChain依据SecurityContext是否已认证决定放行或 401。
  8. 前端响应拦截器统一处理 401,清状态、跳登录。

注意第 6 步,认证对象必须放进当前请求线程的SecurityContext。Spring Security 6 默认策略下上下文不会自动跨线程传递,一旦用了异步 Servlet 或自己开了线程池,父线程塞进去的认证信息在子线程里读不到,表现就是“明明登录了却一直 401”,这是排查时最容易绕晕的一环。

3. 后端落地:过滤器、密钥与 SecurityFilterChain

3.1 用户表与密码存储:先约定再写代码

密码绝不能明文存,也不能用 MD5 加盐凑合。BCrypt 自带随机盐,同一密码每次编码结果都不同,比对时用matches而不是字符串相等。表结构建议最少这几个字段:id、username(唯一索引)、password_hash(60 位左右,给足长度)、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, List<String> 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把过期时间算成绝对时间戳写进exp,parse在校验签名的同时会顺带校验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); List<String> roles = claims.get("roles", List.class); var authorities = roles == null ? List.<GrantedAuthority>of() : 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;charset=UTF-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()是 false,authorizeHttpRequests照样判未认证。

解决:用三参构造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 doesn't 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 key's 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 最大的问题是“要么太短频繁登录,要么太长无法回收”。常见做法是双 token:access 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 里无手机号、身份证直接序列化整个用户对象
密码存储BCrypt,cost ≥ 10MD5 加固定盐
401/403 响应统一 JSON 结构直接返回 HTML 错误页
登出前端清状态 + 服务端失效 refresh只清前端,token 仍可用

6.3 我自己的验证习惯

每次改完认证相关代码,我会固定跑三条断言:拿过期 token 请求受保护接口,必须返回 401 且响应体是 JSON;用篡改过 payload 的 token 请求,必须 401 且日志有签名异常;不带 token 请求公开接口,必须 200。这三条能覆盖绝大多数回归。还有一点血泪经验:任何一次修改SecurityFilterChain之后,都不要只测登录接口,公开接口和受保护接口各挑一个一起测,过滤器顺序调错时往往只坏一半。

从那以后我每次动安全配置,都会把这三条断言先跑一遍再提交,比事后翻日志省事太多。希望帮到你。

本文还有配套的精品资源,点击获取