JWT实战:从Token生成到拦截器配置的完整流程解析

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位以上),并通过环境变量或配置服务器注入,绝不能直接写在代码里提交到版本库。

这个工具类有几个关键点:

  1. 签名算法:我们选择了HS256(HMAC SHA-256),它是一种对称加密算法,使用同一个密钥进行签名和验证。对于大多数内部应用来说,这已经足够安全且高效。
  2. 异常处理parseToken方法会抛出JwtException及其子类(如ExpiredJwtExceptionMalformedJwtException),调用方需要捕获这些异常并做相应处理(如返回401或403状态码)。
  3. 声明管理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。

所以,excludePathPatternsaddPathPatterns中,始终使用以/开头的绝对路径是最安全、最可靠的做法。

除了斜杠问题,路径匹配还支持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。

这需要稍微改造一下拦截器和登录接口:

  1. 登录接口:在返回访问Token的同时,也生成一个有效期更长的刷新Token(比如7天),并一起返回给客户端。刷新Token必须安全存储,可以存到服务器的数据库或Redis中,并关联用户ID。
  2. 拦截器逻辑增强
// 在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的同步请求处理中是正确的。但是,如果你的应用使用了异步处理(如@AsyncWebAsyncTask),由于线程池的线程复用,ThreadLocal中的数据可能会被带到不相干的后续任务中,造成数据错乱和内存泄漏。

解决方案:对于异步场景,可以考虑使用Spring提供的RequestContextHolderDelegatingSecurityContextAsyncTaskExecutor来传递上下文,或者在异步任务开始时手动设置,结束时手动清理。

最后,关于性能,JWT的签名验证是CPU密集型操作。对于超高并发的场景,可以将验证通过的Token信息(如用户ID)缓存在应用本地内存(如Caffeine)或Redis中一小段时间(比如1分钟),在缓存有效期内直接使用缓存结果,避免重复的签名验证计算。但这会牺牲一点点状态的纯粹性,需要根据业务特点权衡。

整个流程走下来,JWT的实现远不止是调用一个库生成字符串那么简单。从安全的密钥管理、精确的路径拦截、到用户体验的Token刷新和强制失效,每一个环节都需要仔细考量。我在第一次完整实现时,光是路径斜杠和异步线程的问题就调试了很久。把这些细节都处理好,你的JWT认证体系才会既安全又健壮。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值