4. 票据验证流程:请求拦截器设计、票据解析与校验、过期时间检查、黑名单机制

票据验证,说白了就是系统的一道安检门。

你想想看,用户拿着票据来访问你的服务,你不能直接放行吧?得先看看这票是真的假的,有没有过期,是不是已经被拉黑了。我见过不少团队,前期图省事,验证逻辑写得特别糙,结果线上出了安全问题,追悔莫及。

这一节,我们就来拆解一下完整的票据验证流程。我会结合我自己的实战经验,把每一步的关键细节都讲透。

4.1 请求拦截器:第一道防线

拦截器是验证的起点。它的职责很简单:在请求到达业务逻辑之前,把票据截住。

我个人习惯把拦截器设计成链式结构。什么意思呢?就是一条链上挂多个拦截器,每个只干一件事。比如:

  • Token 提取器:从 Header、Cookie 或 URL 参数里把票据字符串捞出来。
  • 黑名单检查器:先查一下这个票据是不是已经被拉黑了。如果是,直接返回 401。
  • 票据解析器:把字符串解析成结构化对象。
  • 过期检查器:看看时间戳是不是已经过了。

这样做的好处是,每个环节可以独立测试,出了问题也容易定位。我在项目中遇到过一种情况:某个拦截器里塞了太多逻辑,结果解析失败时,连黑名单都没来得及查,就把一个本该被拦截的请求放过去了。嗯,从那以后,我坚持每个拦截器只做一件事。

核心原则:拦截器只负责“拦截”和“放行”,不负责“处理”。验证通过后,把解析好的票据信息塞进请求上下文,交给后面的业务代码去用。

下面是一个简单的拦截器设计示例:

// 伪代码:请求拦截器链
public class TokenValidationInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, 
                             Object handler) throws Exception {

        // 1. 提取票据
        String token = extractToken(request);
        if (token == null) {
            response.setStatus(401);
            return false;
        }

        // 2. 黑名单检查
        if (blacklistService.isBlacklisted(token)) {
            response.setStatus(401);
            return false;
        }

        // 3. 解析票据
        TokenPayload payload = tokenParser.parse(token);
        if (payload == null) {
            response.setStatus(401);
            return false;
        }

        // 4. 过期检查
        if (payload.getExpireAt() < System.currentTimeMillis()) {
            response.setStatus(401);
            return false;
        }

        // 5. 放行,并把票据信息存入上下文
        request.setAttribute("currentUser", payload);
        return true;
    }
}

4.2 票据解析与校验:别信任何外部输入

票据解析,就是把字符串还原成有意义的数据结构。这一步最容易出问题。

我见过有人直接用 JSON.parse 去解析 JWT 的 Payload,连签名都不验证。这太危险了。你想想看,攻击者完全可以伪造一个 Payload,里面塞个 admin: true,你的系统就以为他是管理员了。

正确的做法是:

  • 先验签名:用服务端的密钥去验证票据的签名。签名不对,直接拒绝。
  • 再验结构:解析出来的字段,类型对不对?有没有缺失必填字段?
  • 最后验内容:比如用户 ID 是不是合法的格式?角色字段是不是在允许的范围内?

注意:解析时一定要做异常捕获。我曾经遇到过一种情况,某个客户端传了一个格式错误的票据,解析器直接抛了空指针异常,导致整个请求处理流程崩溃。从那以后,我在解析代码外面包了一层 try-catch,任何解析异常都统一返回 401。

解析后的数据结构,我一般会设计成这样:

字段 类型 说明
userId String 用户唯一标识
role String 用户角色(如 admin, user)
issuedAt long 签发时间戳(毫秒)
expireAt long 过期时间戳(毫秒)
signature String 签名值(用于完整性校验)

4.3 过期时间检查:别卡死在最后一秒

过期检查看起来简单,就是比一下当前时间和过期时间。但这里有个坑:时钟偏差。

你想想看,签发票据的服务器和验证票据的服务器,时间可能不完全一致。如果验证服务器的时间比签发服务器慢了几秒,那一个刚签发的票据可能就被误判为过期了。

我的做法是引入一个宽容窗口。比如,允许票据在过期后 30 秒内仍然有效。这个窗口不能太大,否则就失去了过期的意义;也不能太小,否则时钟偏差就会导致误杀。

经验值:宽容窗口一般设为 30 秒到 60 秒。对于跨机房部署的场景,可以适当放大到 120 秒。但记住,宽容窗口只用于“过期检查”,不能用于“签发”。签发的过期时间必须是精确的。

检查逻辑的伪代码:

// 过期检查,带宽容窗口
long gracePeriod = 30_000; // 30秒
long now = System.currentTimeMillis();
if (payload.getExpireAt() + gracePeriod < now) {
    // 真的过期了
    throw new TokenExpiredException("票据已过期");
}
// 还在宽容窗口内,放行

4.4 黑名单机制:让票据“死透”

黑名单是最后一道保险。有些场景下,票据还没过期,但用户已经登出了,或者管理员把某个用户封了。这时候,票据必须立即失效。

黑名单的实现方式有很多种:

  • 内存 Set:最简单,但重启就丢了,不适合生产环境。
  • Redis Set:常用方案。用票据的唯一标识(比如 JWT 的 jti)作为 key,过期时间设为票据的剩余有效期。
  • 数据库表:适合需要持久化审计的场景,但性能不如 Redis。

我个人推荐用 Redis。原因很简单:快,而且可以自动过期。你想想看,一个票据的有效期可能是 2 小时,如果它被拉黑了,那 2 小时后它本来就该过期了,Redis 里的记录也可以自动删掉,省得我们手动清理。

关键点:黑名单检查一定要放在票据解析之前。为什么?因为解析本身可能消耗资源(比如解密、验签),如果一个票据已经在黑名单里了,就没必要浪费资源去解析它了。

下面是一个完整的票据验证流程图,我把整个流程串起来,方便你理解:

票据验证完整流程图 1. 请求到达 2. 提取票据 3. 黑名单检查 4. 解析与验签 5. 过期时间检查 6. 放行 / 拒绝 返回 401 返回 401 返回 401 说明:黑名单检查优先于解析验签,避免无效计算。任何一步失败,直接返回 401。

你看,整个流程是线性的,每一步都有明确的拒绝条件。这样做的好处是,逻辑清晰,容易排查问题。我在项目中遇到过一种情况:某个请求被拒绝了,但不知道是哪一步出的问题。后来我在每一步都加了日志,打印出拒绝原因,问题一下子就定位到了。

小技巧:在拦截器里,每一步拒绝时都返回不同的 HTTP 状态码或错误码。比如黑名单拒绝返回 40101,过期拒绝返回 40102。这样客户端可以根据不同的错误码做不同的处理,比如过期了就自动刷新票据,被拉黑了就强制用户重新登录。

好了,票据验证的四个核心环节就讲完了。记住,拦截器是骨架,解析是血肉,过期检查是时间线,黑名单是安全网。把这四块做好了,你的票据验证体系就稳了。