找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

578

积分

0

好友

77

主题
发表于 5 天前 | 查看: 19| 回复: 0

上次面腾讯挂了,这次面字节跳动(抖音安全部),阿强又倒在一道实战题上。面试官抛出一个场景:“假设黑客盗取了用户 Token,或者后台检测到违规账号点击‘立即封禁’,如何让这个用户手里的 JWT 瞬间失效,无法再刷出任何一条视频?”

阿强想都没想就背八股文:“把有效期设短一点,比如 5 分钟。封号后,等它 5 分钟过期就行了。”

JWT Token被盗与强制封禁场景示意图

面试官冷笑一声,连发三问:

  • “让他再逍遥 5 分钟?你知道这 5 分钟黑客能爬走我们多少敏感数据吗?”
  • “如果为了安全把 Token 设为 1 分钟一换,那几亿用户频繁请求续签的交互压力你承担得起吗?”
  • “JWT 的核心优势是‘无状态’,你一旦发出去就像泼出去的水。现在我要你把泼出去的水收回来,你告诉我‘等它自己干’?”

阿强瞬间哑火。这道题是分布式鉴权里的“送命题”。很多人只知道 JWT 方便、能跨域、不用存 Session,却忽略了它最大的硬伤——“请神容易送神难”

今天我们就来拆解 3 种大厂主流的“踢人”方案,从原理到代码落地,讲清楚如何让 JWT 也能被“遥控”。

为什么 JWT 不能“直接”踢人?

要解决问题,先得明白原理。

传统的 Session 模式:用户信息存在服务端的内存或 Redis 里,客户端只拿一个 SessionID

  • 踢人逻辑:服务端直接把这个 SessionID 从 Redis 里删掉。用户下次请求,查不到 Session,直接报错“未登录”。

主流的 JWT 模式:用户信息(UID、Role、过期时间)是加密签在 Token 里的,存在用户本地。服务端不存 Token(为了无状态和水平扩展)。

  • 尴尬点:只要用户的 Token 签名是对的,且时间没过期,网关/服务器就必须认账。你改了数据库里的 status=disabled,但服务器在校验 Token 时根本不去查数据库(为了性能)。

基于数据库状态的传统JWT验证流程图

结论:要想实现“踢人”,JWT 必须“妥协”,引入一定的状态机制。想深入了解这类分布式系统中的权衡设计,可以参阅 后端与架构 板块的讨论。

进阶打法:3种“强制下线”架构与代码落地

方案一:Redis 黑名单机制 (Blacklist) —— 立即生效

这是最接近 Session 体验的方案,安全性最高。

原理:虽然我们不存“有效的 Token”,但我们可以存 “失效的 Token”

网关检查Redis黑名单流程图

逻辑

  1. 踢人时:计算该用户 JWT 的剩余过期时间(比如还剩 30 分钟),把它扔进 Redis 黑名单,并设置相同的 TTL。
  2. 校验时:每次请求进来,先验签名,再多查一次 Redis:这个 Token 在不在黑名单里?

Redis黑名单方案核心逻辑流程图

硬核实战:代码落地 (基于 Spring Boot + Java)

1. KickoutService.java (踢人逻辑)

@Service
public class KickoutService {
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 将某个 Token 拉入黑名单
     * @param token 待作废的 JWT 字符串
     */
    public void kickoutUser(String token) {
        // 1. 解析 Token,获取它的过期时间 (exp)
        Date expiration = JwtUtil.getExpiration(token);
        long now = System.currentTimeMillis();

        // 2. 计算剩余存活时间 (TTL)
        long ttl = expiration.getTime() - now;

        // 3. 如果 Token 还没过期,就把它塞进 Redis 黑名单
        // Key 建议加上前缀,如 "blacklist:token:"
        if (ttl > 0) {
            redisTemplate.opsForValue().set(
                "blacklist:token:" + token,
                "1",
                ttl,
                TimeUnit.MILLISECONDS
            );
        }
    }
}

2. AuthInterceptor.java (网关/拦截器校验)

@Component
public class AuthInterceptor implements HandlerInterceptor {
    @Autowired
    private StringRedisTemplate redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = request.getHeader("Authorization");

        // 第一步:基础校验(校验签名、是否过期),这是 JWT 自带的
        if (!JwtUtil.verify(token)) {
            throw new UnAuthException("Token 无效或已过期");
        }

        // 第二步:查 Redis 黑名单(这是关键!)
        Boolean isBlacklisted = redisTemplate.hasKey("blacklist:token:" + token);
        if (Boolean.TRUE.equals(isBlacklisted)) {
            throw new UnAuthException("您的账号已被强制下线,请联系管理员");
        }
        return true;
    }
}

这类与 Java 生态紧密集成的实战代码,是构建稳健后台服务的基础。

优缺点分析

  • 优点:实时性极高,前脚封号,后脚请求就报错。
  • 缺点:每次请求都要查 Redis,牺牲了一点“无状态”的性能优势。但在安全面前,这点损耗通常值得。

方案二:版本号机制 (Version) —— 一键全退

如果不希望 Redis 里存海量黑名单 Key,或者想实现 “修改密码后,所有设备自动下线” ,这个方案最合适。

原理:给每个用户绑定一个“许可证版本号”。

Token版本与系统版本不匹配示意图

逻辑

  1. 发证:登录时,去 Redis/DB 查该用户的 version(默认是 1),写在 Token 的 payload 里。
  2. 验证:请求来了,拿 Token 里的 v=1 和 Redis 里的当前版本 v=1 比对。
  3. 踢人:把 Redis 里的 version 改成 2。之前所有 v=1 的 Token 就全部失效了。

版本号方案管理流程示意图

硬核实战:代码落地

1. LoginService.java (登录颁发)

public String login(String userId) {
    // 1. 从 Redis 获取用户当前的 version
    String versionKey = "user:version:" + userId;
    String currentVersion = redisTemplate.opsForValue().get(versionKey);

    // 如果没有版本号,初始化为 1
    if (currentVersion == null) {
        currentVersion = "1";
        redisTemplate.opsForValue().set(versionKey, "1");
    }

    // 2. 将 version 写入 Token 的 Payload 中
    Map<String, Object> claims = new HashMap<>();
    claims.put("uid", userId);
    claims.put("v", Integer.parseInt(currentVersion)); // 关键点

    return JwtUtil.createToken(claims);
}

2. UserService.java (改密/踢人)

public void resetPasswordOrKickout(String userId) {
    // 1. 执行修改密码等业务逻辑...
    // 2. 关键步骤:将用户的 Version 自增
    redisTemplate.opsForValue().increment("user:version:" + userId);
}

3. AuthInterceptor.java (拦截器校验)

public boolean preHandle(HttpServletRequest request, ...) {
    String token = request.getHeader("Authorization");
    Claims claims = JwtUtil.parse(token);

    // 1. 获取 Token 里的版本
    Integer tokenVersion = (Integer) claims.get("v");
    String userId = (String) claims.get("uid");

    // 2. 获取系统当前的最新版本
    String sysVersionStr = redisTemplate.opsForValue().get("user:version:" + userId);

    // 3. 比对:如果系统版本变了
    if (sysVersionStr != null) {
        Integer sysVersion = Integer.parseInt(sysVersionStr);
        if (!sysVersion.equals(tokenVersion)) {
            throw new UnAuthException("登录信息已过期(密码可能已修改),请重新登录");
        }
    }
    return true;
}

方案三:双 Token 机制 (Access + Refresh) —— 大厂标配

这是 OAuth2.0 的标准玩法,完美平衡了 性能管控

Access Token与Refresh Token特性对比图

原理

  1. Access Token:有效期极短(比如 5-15 分钟)。完全无状态,网关只验签名,不查 Redis。保证高频请求性能。
  2. Refresh Token:有效期长(比如 7 天)。有状态,保存在 Redis 中。
  3. 踢人逻辑
    • 管理员想踢人时,直接删除 Redis 里的 Refresh Token
    • 用户的 Access Token 虽然还在,但最多只能活几分钟。
    • 等 Access Token 过期,客户端拿 Refresh Token 来换新的。
    • 服务端一查:Refresh Token 没了,拒绝刷新,强制退出。

双Token方案踢人逻辑流程图

硬核实战:代码落地

1. TokenService.java (登录返回双 Token)

public Map<String, String> login(String userId) {
    // 1. 生成短效 Access Token (15分钟),纯计算,不交互
    String accessToken = JwtUtil.createAccessToken(userId, 15, TimeUnit.MINUTES);

    // 2. 生成长效 Refresh Token (7天),并存入 Redis
    String refreshToken = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set(
        "refresh_token:" + userId,
        refreshToken,
        7, TimeUnit.DAYS
    );

    // 3. 返回给前端
    Map<String, String> tokens = new HashMap<>();
    tokens.put("access_token", accessToken);
    tokens.put("refresh_token", refreshToken);
    return tokens;
}

2. AdminService.java (踢人逻辑)

public void kickoutUser(String userId) {
    // 直接删除该用户的 Refresh Token
    redisTemplate.delete("refresh_token:" + userId);
}

3. AuthController.java (刷新 Token 接口)

@PostMapping("/refresh")
public Result refreshToken(@RequestBody String incomingRefreshToken, String userId) {
    // 1. 去 Redis 查该用户合法的 Refresh Token
    String cachedRefreshToken = redisTemplate.opsForValue().get("refresh_token:" + userId);

    // 2. 关键校验:如果 Redis 里查不到或与前端传入的不匹配
    if (cachedRefreshToken == null || !cachedRefreshToken.equals(incomingRefreshToken)) {
        return Result.fail(401, "登录凭证已失效,请重新登录");
    }

    // 3. 校验通过,颁发新的 Access Token
    String newAccessToken = JwtUtil.createAccessToken(userId, 15, TimeUnit.MINUTES);
    return Result.success(newAccessToken);
}

优缺点分析

  • 优点:平时请求完全不查库,QPS 最高。
  • 缺点:踢人有“延迟”(取决于 Access Token 的寿命)。对于绝大多数非金融级业务,这几分钟的延迟是可接受的。

最后的“防杠”指南(扫清死角)

面试官可能会问:“如果 Redis 挂了怎么办?黑名单丢了岂不是封禁用户复活了?

:这是一个关于系统可用性(Availability)与一致性(Consistency)的取舍问题。

Redis故障时的CAP取舍降级方案图

  1. 架构层面:Redis 必须做高可用(哨兵或集群),避免单点故障。
  2. 降级策略:如果 Redis 全崩,可将‘踢人’动作降级为‘数据库标记’,并将高危黑名单同步到应用服务的 JVM 本地缓存(如 Guava Cache) 中。
  3. 业务取舍:在极端情况下,保证普通用户能登录(可用性),暂时允许被踢用户访问几分钟(牺牲一致性),通常是更优解。但涉及 资金转账 等核心接口,必须 强查数据库状态 ,不走 JWT 无状态校验。

总结与建议

三种 JWT 强制下线方案核心对比

维度 方案一:Redis 黑名单 方案二:版本号机制 方案三:双 Token (Access+Refresh)
核心原理 每次请求查 Token 是否在黑名单中 Token 携带版本,比对 Redis 中的用户最新版本 短效 Access Token 访问,踢人时删除 Refresh Token
踢人生效速度 立即生效 (秒级) 立即生效 (秒级) 有延迟 (取决于 Access Token 剩余寿命)
性能损耗 (每次请求必查 Redis) (每次请求必查 Redis) 极低 (平时不查 Redis,仅刷新时查)
存储成本 (黑名单越多,占用越大) 极低 (每个用户只存一个版本号) (每个用户存一个 Refresh Token)
影响范围 精准打击 (只踢当前设备/Token) 全端打击 (踢掉该用户所有设备) 精准打击 (踢掉对应的 Refresh Token)
核心痛点 违背 JWT 无状态初衷,Redis 压力大 无法单独踢掉某个设备,一改密全掉线 踢人有时间差,极端安全场景不适用
推荐指数 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
最佳适用场景 资金/政务系统,对安全性要求极高,零容忍 修改密码、注销账号等需要“一键全退”的场景 90% 的互联网 App ,追求高并发性能

世界上没有绝对的“无状态”。JWT 的无状态是为了换取性能,而在需要“控制权”的时刻(踢人、注销、改密),我们必须引入一点“状态”。

选型建议

  • 想最省心、性能最好:选 方案三(双 Token) 。这是大厂标准,90% 的场景都适用。
  • 对安全性有洁癖(即刻生效):选 方案一(黑名单) 。一个请求都别想溜过去,哪怕牺牲一点 Redis 性能。
  • 想最省内存(全端下线):选 方案二(版本号) 。存一个 Integer 搞定所有设备。

选择适合你业务场景的方案,给你的 JWT 装上一个可靠的“遥控自毁装置”吧。如果你想与更多开发者交流此类架构设计心得,欢迎来到 云栈社区 参与讨论。




上一篇:C++协程库实战:7天从0实现高性能库,性能对标微信libco
下一篇:独立开发者警醒:执着于MVP与产品思维,为何必须放弃完美代码?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-24 02:48 , Processed in 0.267250 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表