最近正好在做一个Spring Boot + Vue的前后端分离项目,用户认证这块一开始用的是传统的Session方案,结果前后端联调时被跨域、Session共享这些问题折腾得够呛。后来狠下心来把认证机制整体换成了JWT,一套改造下来,不仅登录状态的问题彻底解决了,接口的安全性也上了一个台阶。这中间踩了不少坑,也积累了一些比较实用的经验,今天就把整个改造过程完整记录下来,从后端签发Token到前端拦截器携带凭证,再到Token续签和常见安全问题,一次性说清楚,希望能帮到正在做前后端分离项目、准备接入JWT的朋友。这篇内容适合已经会Spring Boot基本开发、Vue能跑起来,但对JWT集成还比较陌生的同学,看完可以直接照着落地。
1. 项目整体设计与技术选型思路
1.1 为什么前后端分离项目更偏向JWT
在做前后端分离之前,传统的单体应用大多依赖Session来维持登录状态。服务端在用户登录成功后创建一个Session对象,把Session ID写入浏览器的Cookie,之后每次请求浏览器自动带上这个Cookie,服务端根据Session ID找到对应的会话数据。
这套机制在同一个域名下的单体应用里跑得很流畅,但一旦前后端分离,问题就来了。前端可能是Vue打包后放在Nginx上的静态资源,后端是独立部署的Spring Boot服务,两者的域名或者端口往往不一样。浏览器跨域请求默认不会携带Cookie,就算你通过配置允许了,Cookie在多个后端实例之间同步也是一件麻烦事,尤其是部署了多个后端节点做负载均衡的时候,要么引入Redis共享Session,要么开启Sticky Session,成本和复杂度都上去了。
JWT的思路完全不同。它把用户身份信息直接编码进一个自包含的Token字符串里,服务端签发后不需要保存任何会话状态。后续请求只要在Header里带上这个Token,服务端验签通过就能确认用户身份。因为Token本身是自包含的,任何后端节点拿到都能独立校验,天然适合分布式部署和前后端分离场景。
我选择JWT的另一个原因是它的结构足够简单。一个JWT由三部分组成:Header(头部)、Payload(载荷)、Signature(签名),中间用点号分隔。Header声明了签名算法和Token类型,Payload存放用户ID、用户名、过期时间这些声明,Signature则是对前两部分用密钥做签名,防止内容被篡改。理解了这三段结构,后面无论是手动解析还是排查问题都会轻松很多。
1.2 技术栈版本选择与库的选型
这次项目的技术栈如下:
- 后端:Spring Boot 2.7.x + JDK 8 + Maven
- 前端:Vue 2.7 + Vue Router 3 + Axios
- JWT库:jjwt 0.11.5
Spring Boot版本我特意选了2.7.x而不是3.x,因为3.x最低要求JDK 17,而且包名从javax换成了jakarta,不少旧项目升级的时候容易踩坑。如果你的项目是全新搭建且环境允许JDK 17,直接上3.x没问题;如果是在老项目上改造,稳妥起见还是2.7.x,省得折腾一堆依赖兼容问题。这个点后面我会单独讲,网上很多人反映"Spring Boot版本太高导致项目跑不起来",大部分就是javax和jakarta的差异引起的。
JWT库我用的jjwt,这个库是目前Java生态里用得最多的,API设计比较清晰,功能也全。0.11.x版本开始引入了Jwts.builder()和Jwts.parserBuilder()这套流式API,用起来很顺手。注意0.9.x和0.11.x的API差别很大,网上一搜JWT教程容易混进老版本的写法,你照着敲会直接编译报错。认准这个版本,网上对应的资料也多。
前端这边,Vue 2.7是官方最后一个大版本,虽然Vue 3已经很成熟,但考虑到团队熟悉度和现有组件库兼容性,这次还是用Vue 2.7。Vue Router用的3.x版本,注意Vue 2对应的Vue Router是3,Vue 3对应的是4,别看混了。Axios负责HTTP请求,拦截器功能正好用来统一处理Token的携带和过期跳转。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端JWT集成:从依赖引入到核心代码
2.1 Maven依赖与配置项
先在pom.xml里引入jjwt相关的依赖。0.11.x版本拆成了三个模块,jjwt-api是编译期依赖,jjwt-impl和jjwt-jackson是运行期依赖,缺一不可:
xml复制<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
jjwt-jackson负责把Payload里的JSON对象和Java对象互转,如果你的项目里已经用了Jackson(Spring Boot默认自带),这个模块能保证序列化行为一致。不用jjwt-jackson而用jjwt-gson也行,但个人建议保持Spring Boot默认生态。
然后在application.yml里加配置项:
yaml复制jwt:
secret: your-secret-key-please-change-to-a-long-random-string-at-least-256-bits
# Token过期时间,单位毫秒,这里设置24小时
expire: 86400000
# 请求头名称
header: Authorization
# Token前缀,注意后面有个空格
prefix: "Bearer "
密钥这里多说一句,HS256算法要求密钥长度至少256位(32字节),上面这个配置串长度不够的话,jjwt启动时会直接报错。生产环境千万别把密钥写死在配置文件里,建议通过环境变量或者配置中心注入,我后面在安全章节会细说。
2.2 封装JWT工具类:签发、解析、校验
接下来写核心的JWT工具类。这个工具类负责三件事:生成Token、解析Token、判断Token是否有效。我习惯把它做成Spring的@Component,通过@Value注入配置项:
java复制@Component
public class JwtUtils {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expire}")
private Long expire;
@Value("${jwt.header}")
private String header;
@Value("${jwt.prefix}")
private String prefix;
private SecretKey getSecretKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
/**
* 生成Token
* @param userId 用户ID
* @param username 用户名
*/
public String generateToken(Long userId, String username) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expire);
return Jwts.builder()
.setSubject(username)
.claim("userId", userId)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(getSecretKey(), SignatureAlgorithm.HS256)
.compact();
}
/**
* 解析Token,返回Claims
*/
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSecretKey())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 判断Token是否有效
*/
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
/**
* 从请求中提取Token
*/
public String getTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(header);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(prefix)) {
return bearerToken.substring(prefix.length());
}
return null;
}
}
这里有几个细节需要注意:
setSubject(username)存的是用户名,claim("userId", userId)是自定义声明,可以存任意想存的信息,比如角色、部门ID,但不要存密码这类敏感数据。Token本身只是签名的,不是加密的,任何人拿到Token都能用Base64解出Payload内容,这个在后面安全章节我再展开。
parseToken里调的是parseClaimsJws,注意看最后的字母是s,不是parseClaimsJwt。前者带签名校验,用于我们签发的完整Token;后者是解析无签名的JWT,校验场景用不到。网上一堆人抄代码抄错这个导致验签失效,一定要记住。
2.3 登录接口:认证成功后才签发Token
有了工具类,下一步就是在登录接口里签发Token。这个过程要理清楚:先校验用户名密码,校验通过才生成Token返回给前端,校验失败直接抛异常。
我这边用了一个自定义的LoginController:
java复制@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Resource
private UserService userService;
@Resource
private JwtUtils jwtUtils;
@PostMapping("/login")
public Result login(@RequestBody LoginDTO loginDTO) {
// 1. 校验验证码(如果有的话)
// 2. 根据用户名查询用户
User user = userService.getByUsername(loginDTO.getUsername());
if (user == null || !passwordEncoder.matches(loginDTO.getPassword(), user.getPassword())) {
return Result.error("用户名或密码错误");
}
// 3. 签发Token
String token = jwtUtils.generateToken(user.getId(), user.getUsername());
// 4. 返回Token和用户基本信息
Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("userInfo", user);
return Result.success(data);
}
@PostMapping("/logout")
public Result logout() {
// 纯前端的登出,直接把本地Token删掉就行
// 如果需要服务端失效,需要引入黑名单机制,后面讲
return Result.success();
}
}
密码校验这里用的是BCryptPasswordEncoder,Spring Security里可以直接拿过来用,比MD5加盐安全得多。有些同学的项目没引入Spring Security,单独引spring-security-crypto这个包也能用BCryptPasswordEncoder,不需要把整个Security框架引进来。
2.4 拦截器实现请求鉴权
Token签发出来之后,最核心的一步是让所有受保护的接口都能自动校验Token。实现方式有过滤器(Filter)、拦截器(Interceptor)、AOP这几种,Spring Boot项目里我推荐用拦截器,原因有三:拦截器能拿到HandlerMethod,可以精细控制哪些方法需要鉴权;拦截器天然支持Spring的依赖注入,方便注入JwtUtils;拦截器的preHandle方法里能直接访问HttpServletRequest和HttpServletResponse,处理401响应很方便。
实现一个JwtInterceptor:
java复制@Component
public class JwtInterceptor implements HandlerInterceptor {
@Resource
private JwtUtils jwtUtils;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 如果是跨域预检请求(OPTIONS),直接放行
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
return true;
}
// 如果不是HandlerMethod(比如静态资源),直接放行
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 获取并校验Token
String token = jwtUtils.getTokenFromRequest(request);
if (token == null || !jwtUtils.validateToken(token)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录或Token已过期\"}");
return false;
}
// 校验通过后,可以顺便把用户信息放到request属性里,供后续业务使用
Claims claims = jwtUtils.parseToken(token);
request.setAttribute("userId", claims.get("userId"));
request.setAttribute("username", claims.getSubject());
return true;
}
}
然后在配置类里注册拦截器,同时配置放行规则:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Resource
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/auth/login",
"/api/auth/register",
"/api/captcha"
);
}
}
这里有个很关键的细节:addPathPatterns("/api/**")只会拦截/api开头的路径。如果你的Controller里的接口路径五花八门,没有统一前缀,拦截器配置起来就会很痛苦。所以设计接口路径时一定要统一前缀,哪怕是/api、/service什么的,对后期加鉴权、加日志、加网关转发都会省很多事。
3. 前端Vue接入JWT:请求拦截与路由守卫
3.1 Axios封装与Token自动携带
后端接口准备好了,前端要做的第一件事是封装Axios,让每次请求都自动把Token塞到请求头里。很多人一上来就每个请求手写headers: { Authorization: 'Bearer ' + token },太原始了,一旦Token存储位置调整,满项目找着改。
我习惯建一个utils/request.js,统一封装:
javascript复制import axios from 'axios'
const service = axios.create({
baseURL: '/api',
timeout: 15000
})
// 请求拦截器
service.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers['Authorization'] = 'Bearer ' + token
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
response => {
return response.data
},
error => {
if (error.response) {
const status = error.response.status
if (status === 401) {
// Token过期或无效,清理本地凭证并跳转登录页
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export default service
响应拦截器里的401处理是整个前端鉴权链路的关键一环。后端拦截器返回401后,前端统一在这里收口,把本地存的Token清掉,然后跳转登录页。这里注意用window.location.href跳转会刷新页面,如果你用的是Vue Router的router.push('/login'),需要先把router引进来。刷新页面的好处是能重置整个应用的状态,避免某些组件还持有旧的登录数据,代价是体验上稍微重一点。看你项目的体量,小项目无所谓,直接跳就行。
3.2 Token存储位置:LocalStorage还是SessionStorage
Token存哪里这个问题,我见过很多团队吵来吵去。这里结合我的实际经验讲清楚各自的优缺点:
| 存储位置 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| localStorage | 持久化保存,刷新页面不丢;关闭浏览器后重新打开仍在 | 有XSS攻击风险;多标签页共享 | 大多数后台管理系统,希望用户登录一次较长时间内有效 |
| sessionStorage | 关闭标签页后自动清除,相对安全 | 多标签页不共享,新开标签页需要重新登录 | 强安全需求,单标签页使用的应用 |
| Cookie | 可以设置HttpOnly属性防XSS读取 | 需要处理跨域携带问题;CSRF风险 | 需要配合HttpOnly场景,前后端同域部署 |
我个人在纯前后端分离的项目里,默认用localStorage。原因很实际:后台管理系统用户不希望一天登录好几次,localStorage的持久性正好契合这个需求。至于XSS风险,核心防护手段是做好前端输入输出转义、不把敏感逻辑塞进前端、配合CSP(内容安全策略)。Token被偷的前提是XSS已经发生,这时候即使Token存内存里也不安全,所以不必过度焦虑。
3.3 Vue Router守卫与登录状态管理
路由守卫是前端控制页面访问权限的关键。Vue Router 3支持全局前置守卫beforeEach,在进入每个页面之前检查是否有Token,没有就踢回登录页:
javascript复制// router/index.js
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
const router = new Router({
routes: [
{ path: '/login', component: () => import('@/views/Login.vue') },
{ path: '/dashboard', component: () => import('@/views/Dashboard.vue'), meta: { requiresAuth: true } },
{ path: '/profile', component: () => import('@/views/Profile.vue'), meta: { requiresAuth: true } },
{ path: '/', redirect: '/dashboard' }
]
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.matched.some(record => record.meta.requiresAuth)) {
if (!token) {
next({ path: '/login', query: { redirect: to.fullPath } })
} else {
next()
}
} else {
next()
}
})
这个meta.requiresAuth标记很灵活,需要登录才能看的页面就加上,公开页面不加。next({ path: '/login', query: { redirect: to.fullPath } })这行的意思是登录成功后可以跳回用户原本想访问的页面,体验细节很加分。登录页拿到redirect参数后在登录成功时把它用上就行。
登录页的核心逻辑大概长这样:
javascript复制async handleLogin() {
const form = this.$refs.form
await form.validate()
const res = await loginApi({ username: this.username, password: this.password })
localStorage.setItem('token', res.token)
localStorage.setItem('userInfo', JSON.stringify(res.userInfo))
this.$router.push(this.$route.query.redirect || '/dashboard')
}
前端这层校验只是用户体验层面的控制,真正的安全边界在后端。别因为前端路由守卫生效就觉得万事大吉,用户完全可以绕过前端界面,直接拿工具调你的后端接口,所以后端拦截器才是鉴权的真正底线。
3.4 Token续签思路:滑动过期方案
JWT有一个天生的痛点:一旦签发,在过期时间之前都是有效的,服务端没法主动让它失效。如果你设置了30分钟过期,用户用着用着突然Token过期被踢下线,体验很差。这时候就要考虑Token续签机制。
续签方案业内主流有两种:刷新Token方案和滑动过期方案。
刷新Token方案是先签发一个短期访问Token(比如2小时)和一个长期刷新Token(比如7天)。访问Token过期后,前端拿刷新Token去换新的访问Token。这个方案安全边界更清晰,但实现复杂度高,前端要处理异步刷新Token的并发场景。
滑动过期方案更简单粗暴:每次用户发起请求,只要Token还有效,就重新签发一个过期时间更长的Token返回给前端,前端更新本地存储。如果用户在过期时间内一直在操作,Token就永远不过期;只有超过一定时间没操作,Token才会自然失效。
如果项目是后台管理系统,用户在线时长要求不那么严格,我推荐先上滑动过期方案,实现代价很小。具体做法是在后端拦截器里,校验通过后用同一个JwtUtils重新生成Token,然后在响应头里写回去:
java复制// 在JwtInterceptor的preHandle中,校验通过后:
if (needRefresh(claims)) {
String newToken = jwtUtils.generateToken(userId, username);
response.setHeader("Authorization", "Bearer " + newToken);
}
前端在响应拦截器里加一段:
javascript复制// 响应拦截器成功回调里
const newToken = error.response.headers['authorization'] || error.response.headers['Authorization']
if (newToken) {
localStorage.setItem('token', newToken.replace('Bearer ', ''))
}
这样每次请求响应都会检查有没有新Token,有就更新。注意判断一下needRefresh的条件,比如距离过期时间还剩不到一半才续,避免每次请求都签发新Token造成无谓的性能开销。
4. 常见安全问题与踩坑记录
4.1 JWT安全:密钥管理、算法混淆与常见攻击
JWT虽然自带签名防篡改,但用不对照样能被攻破。我在排查项目安全时总结了几个高频问题,每一个都是真实踩过的坑。
第一,密钥不能太短。 HS256要求密钥至少32字节,短了jjwt直接报错。更严重的是,有人用"secret"这种弱密钥,攻击者拿到一个JWT就能本地爆破出密钥,然后就可以伪造任意身份的Token。生产环境的密钥一定通过环境变量注入,不要写死在配置文件里,更不要提交到Git仓库。
第二,算法混淆攻击。 这个漏洞很多老教程里都有提到。攻击者把Token的Header里的alg字段从HS256改成none,再手动去掉签名部分,某些解析库如果没限制算法就会把这个Token当成合法Token。jjwt 0.11.x默认不会接受none算法,但如果你用的是其他库或者自己解析Token,一定要限制算法白名单,只允许HS256这一种。
第三,Payload不要放敏感信息。 记住,JWT只是签名不是加密,Payload部分任何拿到Token的人用Base64解码就能看到原文。我见过有人把用户的手机号、身份证号直接塞进Payload,这不是给自己找事吗?需要用户信息时,解析出userId后去数据库查,或者走Redis缓存。
第四,Token泄露风险。 前后端分离项目里Token存在localStorage,被XSS攻击时可能被偷。缓解措施包括:前端做输入输出转义、安装前端安全扫描插件、上线前做一轮XSS专项测试。如果是金融级项目,考虑把Token放到HttpOnly Cookie里,配合CSRF防护,但这会增加不少复杂度,普通业务项目一般不搞。
第五,爆破和重放。 虽然JWT本身有签名保护,但如果用户密码设置得简单,攻击者可以通过不断的登录尝试来获取合法Token,或者通过嗅探网络抓取Token后重放请求。必要的防护手段包括登录接口限流、全站启用HTTPS、敏感操作二次验证。
4.2 Spring Boot版本太高引发的兼容性问题
项目群里经常有人问"为什么我的Spring Boot项目启动报错",版本问题占了很大比例。Spring Boot 3.0开始包名从javax.*改成了jakarta.*,如果你的老项目用的还是javax.servlet.*、javax.annotation.*这类包,升级到3.x后所有import全部编译错误。
这次项目我直接用2.7.x,这是2.x系列最后一个大版本,社区维护稳定,网上资料也多是基于2.x写的,遇到问题搜起来方便。如果你是新建项目,并且确定用JDK 17+,那直接上Spring Boot 3.x + jjwt 0.12.x,注意0.12.x版本在0.11.x基础上又改了一些API。网上搜教程时先看是哪个版本的,版本对不上代码对不上,别硬抄。
还有一个版本坑是Spring Boot 2.7.x + Spring Security 5.x这套组合,如果你引入了Spring Security,WebSecurityConfigurerAdapter已经被废弃了,推荐用SecurityFilterChain的Bean写法。我这次项目没引Security,只用了拦截器,这样组合更简单可控。小项目没必要把Security全家桶搬进来,徒增复杂度。
4.3 跨域配置的隐藏坑
前后端分离项目跨域问题是家常便饭。Vue开发环境走http://localhost:8080,后端接口在http://localhost:8081,必然触发跨域。很多人的第一反应是在后端加@CrossOrigin或者全局CORS配置,但如果你用Token鉴权,跨域配置里有几个细节必须注意。
后端CORS配置里需要显式允许Authorization请求头:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("http://localhost:*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("Authorization", "Content-Type")
.exposedHeaders("Authorization")
.maxAge(3600);
}
}
这里的关键是exposedHeaders("Authorization")。如果没有这一行,前端响应拦截器想从响应头里读取新Token是读不到的,因为浏览器默认只暴露一部分响应头给前端JS读取。我在做Token续签时就被这个坑过,前端一直拿不到后端写进响应头的Authorization,排查了半天才发现是CORS没配置exposedHeaders。
再一个是预检请求的处理。当你的请求带自定义Header(如Authorization)时,浏览器会先发一个OPTIONS预检请求。这个请求不带Token,所以后端拦截器里必须对OPTIONS请求放行,否则预检都过不了,真实请求根本发不出去。我上面的JwtInterceptor里第一行就处理了这个问题,这个顺序不要写反。
4.4 前端必踩:刷新页面Token丢失或过期
前端集成JWT还有一个很常见的现象:用户登录后一切正常,一刷新页面就跳回登录页。排查方向有两个。
第一个是Token没存到localStorage,而是存到了Vuex内存里。Vuex的state是内存态,刷新页面直接清空,自然就丢了。解决方案要么把Token同时存到localStorage,要么用vuex-persistedstate插件做持久化。我习惯的做法是Token只存localStorage,Vuex里只存用户信息,而且用户信息也是从localStorage初始化的。
第二个是Token本身已过期,刷新时路由守卫检查不到有效Token,直接把用户踢回登录页。这种情况体验很差,尤其是用户正在填写一个长表单,刷新一下所有输入都白费了。优化方案是在路由守卫里增加一个Token有效性校验,用解析出来的过期时间判断,而不是只看有没有Token:
javascript复制function isTokenValid() {
const token = localStorage.getItem('token')
if (!token) return false
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.exp * 1000 > Date.now()
} catch (e) {
return false
}
}
这个isTokenValid在路由守卫里调用,Token过期时可以直接跳转登录页并带上redirect参数,用户重新登录后还能回到原页面。如果是刷新页面时恰好过期,体验也没那么糟。注意这里只是前端层面的判断,真正的校验还是以后端拦截器返回401为准。
4.5 关于JWT在线解析工具的那些事
排查JWT问题时,很多人习惯把Token贴到在线解析网站上看内容。这类工具本质就是Base64解码中间那段Payload,确实方便。但我建议在本地开发环境调试时用可以,公司项目尤其是生产环境的Token,尽量别贴到第三方网站上。Token里的Payload即使没有密码,也包含userId、用户名这类信息,属于内部数据的泄露。
更稳妥的做法是在自己的项目里写一个解析接口(仅本地环境开放),或者用现成的JWT调试工具插件在IDE里解析。实在要在线解析,用一些开源自部署的解析工具,数据不经过第三方服务器。安全这根弦什么时候都不能松。
在我的项目里,排查Token问题最常用的手段是先看三段结构是否完整,然后Base64解码Header和Payload看内容是否符合预期,最后用后端日志确认签名是否校验通过。这三个步骤能覆盖90%的JWT异常场景。
5. 项目改造总结与个人经验
这次把Spring Boot + Vue前后端分离项目接入JWT的改造,从后端签发Token、拦截器统一鉴权,到前端Axios携带Token、路由守卫控制页面,再到Token续签和安全加固,整条链路已经完整跑通。项目上线后新认证机制运行稳定,之前的Session跨域共享问题彻底消失了,接口的安全性也有明显提升。
最后根据我的实践经验,给准备做JWT改造的同学几条实在的建议。
第一,接口路径统一加前缀。 无论你用什么认证方案,统一的前缀(比如/api)都是后续扩展的基础。加拦截器、加网关、加日志,全都依赖这个前缀做范围控制。项目初期规划好,后面省心不是一星半点。
第二,后端拦截器是安全底线,前端路由守卫只是体验优化。 前端可以不做Token校验,但后端必须有拦截器兜底。很多项目只做了前端路由守卫,结果绕过前端直接调接口,数据照样返回,这就是典型的"裸奔"状态。
第三,Token过期时间设置要平衡安全和体验。 我见过有的团队直接把Token有效期设成30天,图省事,这其实有隐患。Token长期有效意味着你用泄露的密钥签发的假Token也能长期生效。建议常规接口2-4小时,配合滑动续签机制,既保证体验又不让Token长期裸奔。敏感操作(改密码、转账这类)单独校验身份,不要依赖已有Token。
第四,安全是持续的过程,不是一次性的。 把JWT接入项目只是开始,后续要定期检查密钥、梳理Token使用场景、关注依赖库的安全更新。JWT相关的历史漏洞并不少,及时升级依赖版本也是安全的一部分。
这个改造方案其实还可以继续扩展:比如引入刷新Token实现更精细的双Token机制,或者在前端增加验证码和登录限流来防暴力破解,再或者把Token里保存的用户角色做成动态权限控制。我已经在计划下一轮迭代把这些加上,到时候再写一篇完整的心得分享出来。
