🤖
AI审核中

JWT 安全体系设计:全局失效、单点注销与登录防护实践

Java 30分钟 110浏览 1评论

一、问题背景

JWT 的“无状态”是一把双刃剑。签发后服务端不存储任何会话信息,验证仅依赖密钥——这带来了水平扩展的便利,但也引入了几个棘手问题:

  1. 登出无效:用户点击“退出登录”,但 token 仍在有效期内,被复制留存的副本依然可以继续访问。
  2. 改密不生效:用户修改密码后,旧的 token 仍然有效,因为 JWT 自身并不携带“密码版本”。
  3. 管理员被降级:管理员角色被撤销后,旧的管理员 token 依然可以进入后台。
  4. 暴力破解:登录接口没有限流时,弱密码账号容易被字典爆破。
  5. 多端协同:单机部署时进程重启,内存中的黑名单丢失,已注销的 token 又“复活”。

通意千应 用一套组合拳回答了这些问题:JWT 内嵌 authVersion 做全局撤销、Redis SHA-256 黑名单做单点注销、Lua 脚本做账号/IP 双维限流、过滤器分层校验防绕过。下面逐一拆解。

二、第一层:authVersion——给无状态 JWT 装上“作废开关”

JWT 一经签发便无法撤回,这是其设计的本质特点。解决办法并非让旧 token 本身失效,而是让服务端在验证时拒绝它。

sys_user 表中增加一列 auth_version BIGINT NOT NULL DEFAULT 0,并随 token 一起签发:

private String generateToken(User user, long expirationMillis, String tokenType) {
    Map<String, Object> claims = new HashMap<>();
    claims.put("userId", user.getId());
    claims.put("username", user.getUsername());
    claims.put("role", user.getRole());
    claims.put("tokenType", tokenType);
    claims.put("authVersion", normalizeAuthVersion(user.getAuthVersion()));
    return createToken(claims, user.getUsername(), expirationMillis);
}

验证时,将 token 中的 authVersion 与数据库当前值进行比对:

long currentAuthVersion = databaseUser.getAuthVersion() == null
        ? 0L : databaseUser.getAuthVersion();
if (currentAuthVersion < 0 || jwtUtil.extractAuthVersion(jwt) != currentAuthVersion) {
    log.warn("JWT已被账号安全变更撤销: {}", username);
    rejectAuthentication();
    filterChain.doFilter(request, response);
    return;
}

“作废所有旧 token”就变成了一条 SQL:

UPDATE sys_user SET auth_version = auth_version + 1
WHERE id = #{userId} AND deleted = 0

这条 incrementAuthVersion 在两个关键时机被调用:用户改密码、管理员修改用户信息。一旦执行,所有现存 token 携带的 authVersion 就会小于数据库中的值,验证时立刻被拒绝。

这是一个巧妙的设计权衡:

  • 不需要把所有 token 都拉黑——拉黑需要枚举所有签发过的 token,而服务端根本不知道有哪些 token 还在流通;
  • 不需要等待旧 token 自然过期——作废立即生效;
  • 不需要维护 token 列表——只在用户记录上自增一个数字;
  • 代价是每次请求多一次数据库读——但本来就要读用户表校验账号状态,边际成本几乎为零。

它本质上把“会话作废”从“注销某个 token”升级为“注销某次密码版本之前的所有 token”,完成了从“拉黑”到“版本号”的思维转换。

三、第二层:Redis 黑名单——补齐“单次登出”

authVersion 解决的是“全局撤销”,但用户日常登出只想注销“当前这一台设备”的 token,不应该让其他设备的 token 全部失效。这就需要单点注销能力,而无状态 JWT 本身做不到这一点。

解法是用 Redis 做一个有 TTL 的黑名单,只存储 token 的 SHA-256 摘要而非原始凭证:

public void blacklist(String token, long expiresAtMillis) {
    if (token == null || token.isEmpty()) {
        return;
    }
    long ttlMillis = expiresAtMillis - System.currentTimeMillis();
    if (ttlMillis <= 0) {
        return;
    }
    redisTemplate.opsForValue().set(key(token), "1", ttlMillis, TimeUnit.MILLISECONDS);
}

private String key(String token) {
    byte[] digest = MessageDigest.getInstance("SHA-256")
            .digest(token.getBytes(StandardCharsets.UTF_8));
    StringBuilder hex = new StringBuilder(digest.length * 2);
    for (byte value : digest) {
        hex.append(String.format("%02x", value & 0xff));
    }
    return KEY_PREFIX + hex;
}

几个值得注意的工程细节:

1. TTL 等于 token 剩余有效期ttlMillis = expiresAtMillis - now。token 一旦自然过期就不可能再被使用,黑名单条目也就没有了存在意义,会被自动清理。这避免了黑名单无限增长。

2. 只存 SHA-256,不存原始 token:Redis 是共享存储,运维、备份、慢日志都可能接触到数据。存储摘要而非原文,即使 Redis 被 dump 出去,攻击者也无法直接拿到可用 token。

3. 用 Redis 而非内存 Map:保证多实例部署时黑名单共享,进程重启也不丢失。注释里写得很直白:“避免进程重启或多实例部署后已注销 token 恢复有效”。

4. 验证顺序:先验签,再查黑名单。这是过滤器里的一处关键设计:

if (org.springframework.util.StringUtils.hasText(jwt) && jwtUtil.validateToken(jwt)) {
    // 先验签,再访问 Redis,避免随机垃圾 token 放大黑名单查询压力。
    if (tokenBlacklistService.isBlacklisted(jwt)) {
        rejectAuthentication();
        filterChain.doFilter(request, response);
        return;
    }
    ...
}

如果反过来“先查 Redis 再验签”,攻击者用随机垃圾 token 轰炸就会把 Redis 打成瓶颈。先验签(HS256 是本地对称运算,无网络开销)能过滤掉所有非法 token,只有合法签名的 token 才会去查 Redis。这是“廉价的失败在前,昂贵的检查在后”的经典防御顺序。

登出时,服务将请求中所有呈现的 token 加入黑名单:

private void revoke(Iterable<String> tokens) {
    for (String token : tokens) {
        final long expiresAt;
        try {
            expiresAt = jwtUtil.extractExpiration(token).getTime();
        } catch (JwtException | IllegalArgumentException e) {
            log.debug("Skipping invalid or expired JWT presented during logout: {}", e.getMessage());
            continue;
        }
        // Redis failure intentionally propagates: the endpoint must not claim a successful logout.
        tokenBlacklistService.blacklist(token, expiresAt);
    }
}

注释点出了另一处关键决策:Redis 失败必须向上抛出。如果 Redis 挂了,登出接口还返回“成功”,用户以为已注销,实际 token 仍可用——这是典型的“假安全感”。让接口失败、让用户重试,比让用户误以为安全要强得多。

四、第三层:Lua 脚本限流——账号 + IP 双维度防爆破

光有 token 撤销还不够,登录接口本身的暴力破解必须被挡住。系统同时限制“单账号失败次数”和“单 IP 失败次数”,用一段 Lua 脚本原子地完成“检查 + 抢锁”:

private static final DefaultRedisScript<Long> CHECK_AND_LOCK_SCRIPT = new DefaultRedisScript<>(
        "local a = tonumber(redis.call('GET', KEYS[1]) or '0'); "
                + "local i = tonumber(redis.call('GET', KEYS[2]) or '0'); "
                + "if a >= tonumber(ARGV[1]) or i >= tonumber(ARGV[2]) then return 0; end; "
                + "local locked = redis.call('SET', KEYS[3], ARGV[3], 'NX', 'EX', ARGV[4]); "
                + "if not locked then return 2; end; return 1;",
        Long.class);

这段脚本完成三件事:

  1. 读取账号失败计数 a 和 IP 失败计数 i,任一超阈值就返回 0(拒绝);
  2. SET NX EX 抢一个“登录进行中”锁,防止同一账号并发登录;
  3. 抢锁失败返回 2(提示“正在登录中”),成功返回 1。

为什么必须用 Lua?因为“读计数 → 判断 → 抢锁”如果拆成多条 Redis 命令,并发下会有竞态:两个线程都读到计数未超,都去抢锁,都成功。Lua 在 Redis 单实例上是原子执行的,可以把这段逻辑封装成一个不可分割的操作。

返回值设计也很讲究:用 0/1/2 三个数字区分“限流拒绝 / 允许 / 锁冲突”,调用方据此给出精确提示:

if (Long.valueOf(0L).equals(allowed)) {
    throw new BusinessException(429, "登录失败次数过多,请稍后再试");
}
if (Long.valueOf(2L).equals(allowed)) {
    throw new BusinessException(429, "该账号正在登录验证中,请稍后再试");
}
if (!Long.valueOf(1L).equals(allowed)) {
    throw new BusinessException(503, "登录安全服务暂时不可用");
}
return () -> releaseAttempt(keys.get(2), leaseId);

值得注意的有几点:

双维度限制互补。单账号阈值(默认 5)防“针对某账号爆破”;单 IP 阈值(默认 30)防“一个攻击者用 IP 池爆破多个账号”。两个阈值都很小,但都在 15 分钟窗口内,对真人偶发输错密码足够宽容。

“登录进行中”锁防并发爆破。同一账号同时有多个登录请求时,只有一个能拿到锁,其他全部返回“正在登录中”。这阻断了“同账号并发请求绕过计数”的攻击——因为计数是在 recordFailure 阶段才递增的,若不抢锁,攻击者可以同时发 100 个请求,它们读到的计数都是 0。

释放锁用 leaseId 防误删

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
        "if redis.call('GET', KEYS[1]) == ARGV[1]) then "
                + "return redis.call('DEL', KEYS[1]); end; return 0;",
        Long.class);

这是分布式锁的标准做法:删锁前先比对 leaseId,避免“A 的锁过期后 B 抢到,A 却误删 B 的锁”。AttemptLease 还实现了 AutoCloseable,调用方用 try-with-resources 保证释放:

LoginAttemptService.AttemptLease attemptLease =
        loginAttemptService.checkAllowed(loginRequest.getUsername(), request.getRemoteAddr());
try {
    // 实际登录逻辑
} finally {
    attemptLease.close();
}

Redis Cluster 兼容的 hash tag

return Arrays.asList(
        "security:login:{auth}:account:" + sha256(normalizedUsername),
        "security:login:{auth}:address:" + sha256(normalizedAddress),
        "security:login:{auth}:attempt:" + sha256(normalizedUsername));

三个 key 都带 {auth} 这个 hash tag,保证它们落在 Redis Cluster 的同一个 slot,否则 Lua 脚本跨 slot 执行会直接报错。这是从单机 Redis 升级到集群时最容易踩的坑,提前在 key 设计上规避了。

用户名归一化后 SHA-256:用户名先 trim().toLowerCase() 再哈希,避免 Adminadmin 被当成两个账号绕过计数。哈希则避免在 Redis 里留下可读用户名。

五、第四层:分层过滤器校验——任何一项不过都拒绝

把上面三层串起来的是 JwtAuthenticationFilter,它的校验顺序很讲究:

// 1. 验签(本地、廉价、挡住所有非法 token)
if (StringUtils.hasText(jwt) && jwtUtil.validateToken(jwt)) {
    // 2. 查黑名单(远程、防已注销 token)
    if (tokenBlacklistService.isBlacklisted(jwt)) {
        rejectAuthentication(); filterChain.doFilter(request, response); return;
    }
    // 3. 校验 tokenType 与请求区域匹配(管理员 token 不能访问用户接口,反之亦然)
    String tokenType = jwtUtil.extractTokenType(jwt);
    String expectedType = isAdminRequest ? JwtUtil.TOKEN_TYPE_ADMIN : JwtUtil.TOKEN_TYPE_USER;
    if (!expectedType.equals(tokenType)) {
        rejectAuthentication(); filterChain.doFilter(request, response); return;
    }
    // 4. 加载用户、校验账号状态(启用、未过期、未锁定)
    UserDetails userDetails = userDetailsService.loadUserByUsername(username);
    if (!userDetails.isEnabled() || !userDetails.isAccountNonExpired()
            || !userDetails.isAccountNonLocked() || !userDetails.isCredentialsNonExpired()) {
        rejectAuthentication(); filterChain.doFilter(request, response); return;
    }
    // 5. 校验 token 内 userId 与数据库一致(防 token 被篡改 userId)
    if (!(userDetails instanceof User)
            || !jwtUtil.extractUserId(jwt).equals(((User) userDetails).getId())) {
        rejectAuthentication(); filterChain.doFilter(request, response); return;
    }
    // 6. 校验 authVersion(防改密后旧 token 仍可用)
    if (currentAuthVersion < 0 || jwtUtil.extractAuthVersion(jwt) != currentAuthVersion) {
        rejectAuthentication(); filterChain.doFilter(request, response); return;
    }
    // 7. 管理员请求还必须实时校验 ROLE_ADMIN(防管理员被降级后旧 token 仍可进后台)
    if (isAdminRequest) {
        boolean hasAdminRole = userDetails.getAuthorities().stream()
                .anyMatch(auth -> auth.getAuthority().equals("ROLE_ADMIN"));
        if (!hasAdminRole) {
            rejectAuthentication(); filterChain.doFilter(request, response); return;
        }
    }
    // 8. 全部通过才设置认证上下文
    SecurityContextHolder.getContext().setAuthentication(authentication);
}

这个顺序的设计逻辑是“校验成本从低到高,任一失败立即短路”:

  • 验签(CPU) → 黑名单(网络) → tokenType(CPU) → 用户状态(DB 读) → userId 一致性(CPU) → authVersion(已在 DB 读结果中) → 实时角色(已在 DB 读结果中)。

最后两步尤其值得一提:authVersion 校验和实时角色校验都在 loadUserByUsername 之后,意味着只读一次数据库就能完成所有“与服务端状态同步”的检查,没有为 authVersion 单独再查一次库。这种“一次 DB 读,多项校验”的编排将远程 IO 压到了最低。

tokenType 校验是个常被忽略的细节:用户 token 和管理员 token 用同一密钥签发,如果不校验类型,普通用户拿到自己的 token 就能访问管理员接口。这条检查把两套 token 隔离成两个独立区域。

六、第五层:密钥与签发的硬约束

签发侧也有几处防御性设计,集中在 JwtUtil.init

@PostConstruct
public void init() {
    if (jwtSecret == null || jwtSecret.trim().isEmpty() || !jwtSecret.equals(jwtSecret.trim())) {
        throw new IllegalStateException("JWT_SECRET 未配置或包含首尾空白");
    }
    if (jwtSecret.getBytes(StandardCharsets.UTF_8).length < 32) {
        throw new IllegalStateException("JWT_SECRET 至少需要32字节");
    }
    ...
    this.key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
}
  • 拒绝首尾空白:配置文件里 jwt.secret = xxx 末尾的空格会让密钥与预期不符,启动时直接失败比运行时验签全部失败更好排查;
  • 强制至少 32 字节:HS256 的安全基线,短密钥容易被暴力破解;
  • 校验 issuer/audience:解析时通过 requireIssuer(jwtIssuer).requireAudience(jwtAudience),防止跨服务的 token 串用——即使两个服务用同一密钥,token 也不能互通。

签发时还塞了 setId(UUID) 作为 JWT 唯一 ID,为未来可能的“单 token 追踪/撤销”留了扩展点。

七、把这套设计串起来看

一次完整的“用户改密码后旧设备尝试访问”场景:

  1. 用户在设备 A 改密码 → incrementAuthVersion 执行,sys_user.auth_version 从 0 变为 1;
  2. 设备 B 上的旧 token 携带 authVersion=0,发起请求;
  3. 过滤器验签通过 → 黑名单未命中 → tokenType 正确 → 用户状态正常 → authVersion 比对失败(0 ≠ 1)→ 拒绝
  4. 用户在设备 A 主动登出 → token 进入 Redis 黑名单(TTL = 剩余有效期);
  5. 设备 A 上残留的 token 副本再发请求 → 验签通过 → 黑名单命中 → 拒绝
  6. 攻击者拿到某个泄露的 token 想爆破其他账号 → 登录接口被 Lua 限流挡住,5 次失败后账号锁定 15 分钟,30 次失败后 IP 锁定 15 分钟;
  7. 攻击者并发发送 100 个登录请求想绕过计数 → “登录进行中”锁让 99 个直接返回“正在登录验证中”。

整个过程中,无状态 JWT 的“易扩展”优势被完整保留(验证仍主要靠密钥),而它的“难撤销”短板被 authVersion + 黑名单补齐,登录接口的“易爆破”短板被 Lua 限流补齐。

八、客观看它的局限

这套设计并非银弹:

  • authVersion 每请求一次 DB 读:把无状态 JWT 拉回了“有状态”的 IO 成本。对超高并发场景,可以把 authVersion 缓存进 Redis 并设置短 TTL(如 30 秒),接受最多 30 秒的撤销延迟,换取 DB 压力下降。系统目前没有做这层缓存,对个人 AI 聊天项目的规模已经够用。
  • 黑名单依赖 Redis 可用性:Redis 全挂时登出会失败(这是有意为之的“安全失败”),但已注销 token 会“复活”直到 Redis 恢复。这可以接受,但要知道这个窗口的存在。
  • 限流是单维度账号 + 单维度 IP:没有设备指纹、没有行为分析,对分布式 IP 池爆破防御有限。生产级风控会叠加更多维度。
  • JWT 不可吊销的本质没变:黑名单只是“软撤销”,若攻击者能在 Redis 写入任意 key,理论上可以擦除黑名单。Redis 的访问控制仍需保障。

九、小结

这套 JWT 安全设计的价值,在于它示范了如何用最小的工程成本,把无状态 JWT 的撤销短板补到“可用”级别。核心模式可以归纳为:

  • 版本号优于黑名单:用 authVersion 一列解决“全局撤销”,避免枚举所有 token;黑名单只用于“单点注销”这类版本号管不到的场景;
  • SHA-256 摘要优于原始凭证:共享存储里只存不可逆摘要,降低泄露面;
  • TTL 跟随 token 过期:黑名单条目随 token 自然失效自动清理,无需额外清扫任务;
  • 廉价检查在前,昂贵检查在后:先验签再查 Redis,先本地后网络,挡住垃圾流量放大攻击;
  • Lua 原子化多键操作:限流这类“读-判-写”必须原子化,Lua + hash tag 是 Redis Cluster 下的标准解法;
  • leaseId 防误删锁:分布式锁的标准实践,不能省;
  • 失败必须可见:Redis 挂时登出报错而非假装成功,宁可不可用也不给假安全感。

它再次印证了一条经验:JWT 的安全不在于签发,而在于撤销。无状态带来的便利永远伴随着“作废难”的代价,而这套 authVersion + 黑名单 + 限流的组合,用一张用户表列、一个 Redis key 前缀和一段 Lua 脚本,把这个代价压到了可接受的下限。这些模式(版本号撤销、摘要素引黑名单、Lua 原子限流、分层短路校验)几乎可以原样迁移到任何用 JWT 做会话的系统。

1 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  1 条评论
伴我   湖南省衡阳市

每日一学guzhang