JWT实战:从Token生成到拦截器配置的完整流程解析
最近在重构一个老项目的用户认证模块,决定把传统的Session方案换成JWT。本以为是个简单的替换,结果从Token生成、拦截器配置到路径排除,每一步都踩了坑。尤其是拦截器里那个excludePathPatterns的路径配置,一个斜杠“/”的缺失,直接让登录接口拿到的Token值变成了null,调试了大半天才找到原因。这篇文章,我就把自己从零搭建JWT认证体系的完整流程,以及那些容易忽略的细节,系统地梳理一遍。无论你是刚接触JWT,还是想优化现有实现,希望这些实战经验能帮你少走弯路。
1. JWT核心概念与Token生成实战
在动手写代码之前,我们得先搞清楚JWT到底是什么,以及它为什么适合现代应用。JWT,全称JSON Web Token,本质上是一个经过数字签名或加密的、包含声明信息的字符串。它由三部分组成,用点号分隔:Header.Payload.Signature。这种结构让它天生具备自包含性,服务器无需在内存或数据库中存储会话状态,只需验证Token的签名即可确认其有效性,非常适合分布式系统和前后端分离的架构。
Payload(载荷) 是JWT的核心,里面存放了我们真正关心的数据,比如用户ID、用户名、角色和Token的过期时间。这些数据被称为“声明”。需要注意的是,Payload虽然经过了Base64Url编码,但并未加密,任何人都可以解码看到内容。因此,绝对不要在Payload里存放敏感信息,比如密码、银行卡号。
下面,我们来动手实现一个健壮的JwtUtil工具类。这个类将封装Token的生成、解析和验证逻辑。
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.Map;
public class JwtUtil {
// 使用强密钥,生产环境应从安全的配置中心获取,切勿硬编码
private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor("your-256-bit-secret-key-here-must-be-very-long".getBytes());
// Token默认有效期为1小时
private static final long EXPIRATION = 3600_000L;
/**
* 生成JWT Token
* @param claims 自定义声明,如用户ID、用户名
* @return 签名的JWT字符串
*/
public static String genToken(Map<String, Object> claims) {
// 设置过期时间
long currentTime = System.currentTimeMillis();
Date now = new Date(currentTime);
Date expiryDate = new Date(currentTime + EXPIRATION);
return Jwts.builder()
.setClaims(claims) // 设置自定义声明
.setIssuedAt(now) // 签发时间
.setExpiration(expiryDate) // 过期时间
.signWith(SECRET_KEY, SignatureAlgorithm.HS256) // 使用HS256算法和密钥签名
.compact();
}
/**
* 解析并验证JWT Token
* @param token JWT字符串
* @return 包含声明信息的Claims对象
* @throws JwtException 如果Token无效、过期或签名错误
*/
public static Claims parseToken(String token) throws JwtException {
if (token == null || token.isBlank()) {
throw new MalformedJwtException("Token cannot be null or empty");
}
// 解析器会同时验证签名和过期时间
return Jwts.parserBuilder()
.setSigningKey(SECRET_KEY)
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 从Token中获取指定声明
* @param token JWT字符串
* @param claimName 声明名称
* @return 声明的值
*/
public static <T> T getClaimFromToken(String token, String claimName, Class<T> clazz) {
Claims claims = parseToken(token);
return claims.get(claimName, clazz);
}
}
注意:上面的
SECRET_KEY是示例,在实际项目中,必须使用足够长且复杂的密钥(建议256位以上),并通过环境变量或配置服务器注入,绝不能直接写在代码里提交到版本库。
这个工具类有几个关键点:
- 签名算法:我们选择了
HS256(HMAC SHA-256),它是一种对称加密算法,使用同一个密钥进行签名和验证。对于大多数内部应用来说,这已经足够安全且高效。 - 异常处理:
parseToken方法会抛出JwtException及其子类(如ExpiredJwtException、MalformedJwtException),调用方需要捕获这些异常并做相应处理(如返回401或403状态码)。 - 声明管理:
genToken方法接收一个Map,方便我们灵活地放入任何需要的用户信息。
2. 构建Spring Boot拦截器与认证流程
Token生成好了,接下来就要在请求到达Controller之前进行拦截和验证。Spring MVC的拦截器(HandlerInterceptor)是这个任务的绝佳选择。我们需要创建一个拦截器,从HTTP请求头中提取Token,验证其有效性,并将用户信息传递到后续的业务逻辑中。
这里有一个非常重要的设计决策:如何将已验证的用户信息安全地传递给Service层? 常见的方案有:
- 放入HttpServletRequest属性中:简单,但强依赖于Web容器。
- 使用SecurityContextHolder:Spring Security的标准做法,功能强大但稍重。
- 使用ThreadLocal:轻量级,线程隔离性好,非常适合在拦截器到Controller/Service之间传递数据。
我选择了ThreadLocal方案,因为它足够轻量且与Spring MVC集成无侵入性。先创建一个ThreadLocalUtil工具类:
public class ThreadLocalUtil {
private static final ThreadLocal<Object> THREAD_LOCAL = new ThreadLocal<>();
public static void set(Object value) {
THREAD_LOCAL.set(value);
}
@SuppressWarnings("unchecked")
public static <T> T get() {
return (T) THREAD_LOCAL.get();
}
public static void remove() {
THREAD_LOCAL.remove();
}
}
然后,实现核心的登录拦截器:
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 从请求头中获取Token,通常放在Authorization头,格式为"Bearer <token>"
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
// 没有Token,直接返回未授权
sendUnauthorizedResponse(response, "Missing or invalid Authorization header");
return false;
}
String token = authHeader.substring(7); // 去掉"Bearer "前缀
try {
// 2. 解析并验证Token
Claims claims = JwtUtil.parseToken(token);
// 3. (可选)进行额外的业务检查,如检查用户状态是否被禁用
// if (!userService.isActive(claims.getSubject())) { ... }
// 4. 将用户信息存入ThreadLocal,供后续使用
ThreadLocalUtil.set(claims);
// 5. 验证通过,放行请求
return true;
} catch (ExpiredJwtException e) {
sendUnauthorizedResponse(response, "Token has expired");
return false;
} catch (JwtException e) {
// 捕获其他JWT异常,如签名错误、格式错误等
sendUnauthorizedResponse(response, "Invalid token: " + e.getMessage());
return false;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 请求处理完成后,务必清除ThreadLocal中的数据,防止内存泄漏
ThreadLocalUtil.remove();
}
private void sendUnauthorizedResponse(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
response.setContentType("application/json;charset=UTF-8");
// 可以返回更结构化的错误信息
response.getWriter().write("{\"code\": 401, \"msg\": \"" + message + "\"}");
}
}
拦截器的逻辑很清晰:preHandle中验证Token,通过则放行,不通过则拦截并返回401;afterCompletion中清理线程变量。这里我特别处理了Authorization头的标准格式(Bearer Token),并细化了不同异常情况下的错误信息返回,这对前端调试非常友好。
3. 拦截器注册与路径排除的精确配置
拦截器写好了,但并不是所有请求都需要经过它。像用户注册、登录、公开的API文档接口等,显然不应该要求携带Token。这就需要我们在注册拦截器时,精确地配置哪些路径需要排除(exclude)。
Spring MVC通过WebMvcConfigurer接口的addInterceptors方法来注册和配置拦截器。这里的配置看似简单,却隐藏着最容易出错的细节。
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/api/**") // 拦截所有/api开头的路径
.excludePathPatterns(
"/api/user/login", // 排除登录
"/api/user/register", // 排除注册
"/swagger-ui/**", // 排除Swagger UI
"/v3/api-docs/**", // 排除OpenAPI文档
"/error" // 排除Spring Boot默认错误页
);
}
}
路径配置的“魔鬼细节”
我最初遇到的Token为null的问题,根源就在于excludePathPatterns的路径写法。这里必须明确Spring MVC的路径匹配规则:
- 绝对路径:以
/开头的路径,如"/api/user/login",会从应用的上下文根路径(Context Path)开始匹配。 - 相对路径:不以
/开头的路径,如"api/user/login",其匹配行为是未定义的,很可能导致拦截器无法正确识别和排除该路径,使得本应放行的请求也被拦截,从而在拦截器中获取不到Token。
所以,在excludePathPatterns和addPathPatterns中,始终使用以/开头的绝对路径是最安全、最可靠的做法。
除了斜杠问题,路径匹配还支持Ant风格的通配符:
?:匹配单个字符。*:匹配0个或多个字符,但仅限于单层路径。**:匹配0层或多层路径。
例如,/api/*/detail可以匹配/api/order/detail,但不能匹配/api/order/123/detail。而/api/**/detail则可以匹配后者。
提示:在开发过程中,如果你不确定拦截器的路径匹配是否生效,可以开启Spring Boot的调试日志(
logging.level.org.springframework.web=DEBUG),观察拦截器的注册和匹配过程。
4. 实战中的常见问题排查与进阶优化
配置完成后,整个JWT认证流程就可以跑通了。但在实际开发和线上运行中,你可能会遇到一些典型问题。下面我结合自己的踩坑经历,总结几个排查思路和优化方案。
问题一:拦截器生效了,但Filter里拿不到用户信息?
Spring MVC的请求处理链路是:Filter -> DispatcherServlet -> Interceptor -> Controller。如果你在自定义的Filter里尝试从ThreadLocalUtil获取用户信息,会发现是null。这是因为Filter的执行顺序在Interceptor之前。
解决方案:如果需要在Filter层面进行基于JWT的操作(比如全局日志记录用户ID),你应该在Filter中自己解析Token,或者确保该Filter的注册顺序在Spring Security的FilterChain之后。更常见的做法是将这类逻辑放在拦截器中处理。
问题二:Token过期了,如何实现“静默刷新”?
这是提升用户体验的关键。我们可以在拦截器验证Token时,如果发现Token过期(捕获到ExpiredJwtException),但请求中携带了一个有效的“刷新Token”(Refresh Token),则自动颁发新的访问Token。
这需要稍微改造一下拦截器和登录接口:
- 登录接口:在返回访问Token的同时,也生成一个有效期更长的刷新Token(比如7天),并一起返回给客户端。刷新Token必须安全存储,可以存到服务器的数据库或Redis中,并关联用户ID。
- 拦截器逻辑增强:
// 在preHandle的catch块中处理过期异常
catch (ExpiredJwtException e) {
String refreshToken = request.getHeader("X-Refresh-Token");
if (refreshToken != null && refreshTokenService.validate(refreshToken)) {
// 刷新Token有效,生成新的访问Token
String newAccessToken = jwtUtil.genToken(e.getClaims());
// 将新Token放到响应头中(如 X-New-Access-Token)
response.setHeader("X-New-Access-Token", newAccessToken);
// 放行请求,业务层可能不需要感知这次刷新
ThreadLocalUtil.set(e.getClaims()); // 使用旧Token的声明(通常是用户信息)
return true;
} else {
sendUnauthorizedResponse(response, "Token expired and refresh failed");
return false;
}
}
问题三:如何实现Token的黑名单/强制失效?
JWT本身是无状态的,一旦签发,在过期前都有效。如果我们需要实现用户主动登出、或管理员禁用用户后立即使其Token失效,就需要引入一个轻量的状态管理——黑名单。
一个简单的实现是使用Redis:
- 登出时,将尚未过期的Token的剩余有效时间计算出来,以
jwt:blacklist:<token签名部分>为key,存入Redis,并设置TTL为剩余时间。 - 在拦截器验证Token通过后,增加一步检查:去Redis中查询该Token是否在黑名单中。
// 在拦截器parseToken成功后
String tokenSign = // 提取Token的签名部分(或直接用整个Token的哈希值)
if (redisTemplate.hasKey("jwt:blacklist:" + tokenSign)) {
sendUnauthorizedResponse(response, "Token has been revoked");
return false;
}
问题四:ThreadLocal的内存泄漏风险
我们在拦截器的afterCompletion中调用了ThreadLocalUtil.remove(),这在Spring MVC的同步请求处理中是正确的。但是,如果你的应用使用了异步处理(如@Async、WebAsyncTask),由于线程池的线程复用,ThreadLocal中的数据可能会被带到不相干的后续任务中,造成数据错乱和内存泄漏。
解决方案:对于异步场景,可以考虑使用Spring提供的RequestContextHolder或DelegatingSecurityContextAsyncTaskExecutor来传递上下文,或者在异步任务开始时手动设置,结束时手动清理。
最后,关于性能,JWT的签名验证是CPU密集型操作。对于超高并发的场景,可以将验证通过的Token信息(如用户ID)缓存在应用本地内存(如Caffeine)或Redis中一小段时间(比如1分钟),在缓存有效期内直接使用缓存结果,避免重复的签名验证计算。但这会牺牲一点点状态的纯粹性,需要根据业务特点权衡。
整个流程走下来,JWT的实现远不止是调用一个库生成字符串那么简单。从安全的密钥管理、精确的路径拦截、到用户体验的Token刷新和强制失效,每一个环节都需要仔细考量。我在第一次完整实现时,光是路径斜杠和异步线程的问题就调试了很久。把这些细节都处理好,你的JWT认证体系才会既安全又健壮。

1万+

被折叠的 条评论
为什么被折叠?



