阿强(这兄弟真是打不死的小强,上次腾讯挂了,这次又冲了)又来找我复盘了。 这次他去面了 字节跳动(抖音安全部)。

面试官抛出了一个极其实战的场景题: “假设一个黑客盗取了用户的 Token,或者我们后台检测到违规账号点击了‘立即封禁’,如何让这个用户手里的 JWT 瞬间失效,无法再刷出任何一条视频?”

阿强想都没想,直接背八股文: “简单啊!JWT 本身包含过期时间(Exp),我们把有效期设短一点,比如 5 分钟。封号之后,等他 5 分钟过期不就行了?”

图片

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

  • “让他再逍遥 5 分钟?你知道这 5 分钟黑客能爬走我们多少敏感数据吗?”

  • “如果为了安全把 Token 设为 1 分钟一换,那几亿用户频繁请求续签的交互压力你承担得起吗?”

  • “JWT 的核心优势是‘无状态’,你一旦发出去就像泼出去的水。现在我要你把泼出去的水收回来,你告诉我‘等它自己干’?”

阿强瞬间哑火,支支吾吾半天说不出话,最后只能灰溜溜地出来。

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

今天 Fox 就带你拆解 3 种大厂主流的“踢人”打法,从“小作坊”到“金融级架构”,让你下次面试能把面试官聊得心服口服。

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

要解决问题,先得明白原理。这也是面试官最爱挖坑的地方。

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

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

2. 现流的 JWT 模式 用户信息(UID、Role、过期时间)是加密签在 Token 里的,存在用户浏览器的 LocalStorage 里。服务端是不存 Token 的(为了无状态,方便水平扩展)。

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

图片

结论: 要想实现“踢人”,JWT 必须“妥协”,引入一定的状态机制。

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

我们由浅入深,看看哪种适合你的业务。

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

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

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

图片

逻辑:

  1. 踢人时: 计算该用户 JWT 的剩余过期时间(比如还剩 30 分钟),把它扔进 Redis 黑名单。

  2. 校验时: 每次请求进来,先验签名,再多查一次 Redis:这个 Token 在不在黑名单里?

图片

硬核实战:代码落地

1. KickoutService.java (踢人逻辑)

@Service
publicclass KickoutService {

    @Autowired
    private StringRedisTemplate redisTemplate;

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

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

        // 3. 如果 Token 还没过期,就把它塞进 Redis 黑名单
        // Key 建议加上前缀,如 "blacklist:token:"
        // Value 随便填,TTL 设为剩余时间(因为它过期后,黑名单也没意义了)
        if (ttl > 0) {
            redisTemplate.opsForValue().set(
                "blacklist:token:" + token, 
                "1", 
                ttl, 
                TimeUnit.MILLISECONDS
            );
        }
    }
}

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

@Component
publicclass 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)) {
            thrownew UnAuthException("Token 无效或已过期");
        }

        // 第二步:查 Redis 黑名单(这是关键!)
        // 如果 Redis 里有这个 Key,说明被管理员踢了
        Boolean isBlacklisted = redisTemplate.hasKey("blacklist:token:" + token);
        if (Boolean.TRUE.equals(isBlacklisted)) {
            thrownew UnAuthException("您的账号已被强制下线,请联系管理员");
        }

        returntrue;
    }
}

优缺点分析:

  • 优点: 实时性极高,前脚点封号,后脚请求就报错。

  • 缺点: 每次请求都要查 Redis,稍微牺牲了一点“无状态”的性能优势。但在安全面前,这点损耗完全值得。

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

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

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

图片

逻辑:

  1. 发证: 登录时,去 Redis/DB 查该用户的 version(默认是 1),写在 Token 里。

  2. 验证: 请求来了,拿 Token 里的 v=1 和 Redis 里的当前版本 v=1 比对。

  3. 踢人: 把 Redis 里的 version 改成 2。之前的 Token (v=1) 就全部失效了。

图片

硬核实战:代码落地

1. LoginService.java (登录颁发)

public String login(String userId) {
// 1. 从 Redis 获取用户当前的 version
// Key 示例: "user:version:10086"
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 自增
// 这样之前颁发的所有旧版本 Token (v=1) 都会瞬间失效,因为 Redis 里已经是 2 了
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. 比对:如果系统版本变了,或者 Token 里没版本号
    if (sysVersionStr != null) {
        Integer sysVersion = Integer.parseInt(sysVersionStr);
        if (!sysVersion.equals(tokenVersion)) {
            thrownew UnAuthException("登录信息已过期(密码可能已修改),请重新登录");
        }
    }
    returntrue;
}

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

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

图片

原理:

  1. Access Token: 有效期极短(比如 5-15 分钟)。完全无状态,网关只验签名,不查 Redis。这保证了高频请求的性能。

  2. Refresh Token: 有效期长(比如 7 天)。有状态,保存在 Redis 中。

  3. 踢人逻辑:

    • 管理员想踢人时,直接删除 Redis 里的 Refresh Token。

    • 用户的 Access Token 虽然还在,但最多只能活几分钟。

    • 等 Access Token 过期了,客户端拿着 Refresh Token 来换新的。

    • 服务端一查: Refresh Token 没了(或被拉黑了),拒绝刷新,强制退出登录。

图片

硬核实战:代码落地

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

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

// 2. 生成长效 Refresh Token (7天)
// 这是一个随机字符串,或者也是个 JWT,但关键是它要存 Redis
String refreshToken = UUID.randomUUID().toString();

// 3. 将 Refresh Token 存入 Redis,绑定用户
redisTemplate.opsForValue().set(
    "refresh_token:" + userId, 
    refreshToken, 
    7, TimeUnit.DAYS
);

// 4. 返回给前端
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
    // 此时用户的 Access Token 还能用几分钟,但这是一个允许的 Trade-off
    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 里查不到(说明过期了,或者被管理员踢了)
    // 或者前端传来的跟 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)的取舍问题。

图片

  1. 架构层面: Redis 必须做高可用(Sentinel 哨兵或 Cluster 集群),不能单点故障。

  2. 降级策略: 如果 Redis 真的全崩了,我们可以将‘踢人’动作降级为‘数据库标记’,并将关键的高危黑名单(比如涉及资金安全的)同步到应用服务的JVM 本地缓存(Guava Cache)中。

  3. 业务取舍: 在极端情况下,保证普通用户能登录(可用性),暂时允许被踢用户访问几分钟(牺牲一致性),通常是更优解。但涉及资金转账等核心接口,必须强查数据库状态,不走 JWT 这一套。”

四、 总结与建议

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

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

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

Fox 给兄弟们的选型建议:

  • 想最省心、性能最好: 选 方案 3(双 Token)。这是大厂标准,90% 的场景都适用。

  • 对安全性有洁癖(即刻生效): 选 方案 1(黑名单)。一个请求都别想溜过去,哪怕牺牲一点 Redis 性能。

  • 想最省内存(全端下线): 选 方案 2(版本号)。存一个 Integer 搞定所有设备。

别再让你的系统“裸奔”了,给你的 JWT 装上一个“遥控自毁装置”吧!

https://mp.weixin.qq.com/s/7ZyJVq8HdbjksfBsc6tb6g

更多推荐