介绍一下常用的Token鉴权方案

0 阅读19分钟

介绍一下常用的Token鉴权方案

整体认知

Token 鉴权本质是 "凭证验证" 机制:客户端先向认证服务器提交身份凭证,服务器验证通过后颁发一个加密的 Token,后续所有请求都携带这个 Token,服务器只需要验证 Token 的合法性即可,不需要再查询数据库。

主流鉴权方案对比 📊

方案名称核心原理状态性安全性扩展性典型适用场景
Session-Cookie服务器存储 Session,客户端存 SessionID有状态中等(易受 CSRF 攻击)差(分布式需共享 Session)小型单体应用、内部管理系统
JWT服务器不存储,Token 本身包含所有信息无状态高(签名防篡改)极好前后端分离、移动端、微服务
OAuth2.0第三方授权,不暴露用户密码无状态极高极好第三方登录(微信 / QQ 登录)、开放平台
SSO 单点登录一处登录,多处访问无状态极好企业级多系统、集团内部应用

整理了面试真题、每日技术知识点、系统学习路线,都汇总在个人网站 [www.javadashen.com] 有需要的同学自取。

公众号「Rain的Java大神之路」每天拆一个知识点,陪你悄悄变强,有空来坐坐。


各方案核心详解

1. JWT(JSON Web Token)🔥 最常用

JWT 是目前互联网公司最主流的无状态鉴权方案,结构为三段式:Header.Payload.Signature

image.png

  • 优点:无状态,服务器不需要存储,天然支持分布式;跨语言;轻量
  • 缺点:Token 一旦颁发无法主动注销;Payload 不能存敏感信息;过期时间不好控制
  • 生产级优化
    • 采用双 Token 机制:短有效期 AccessToken(15-30 分钟)+ 长有效期 RefreshToken(7-14 天)
    • 用 Redis 维护黑名单实现主动注销(只存过期前的 Token,内存占用极低)
    • 必须使用 HTTPS 传输,防止 Token 被窃取

2. OAuth2.0 🔑 第三方授权

核心思想是 "授权而非认证",让第三方应用在不获取用户密码的情况下访问用户资源。

四种授权模式:

  1. 授权码模式(最安全,推荐):先获取授权码,再用授权码换 Token
  2. 简化模式:前端直接获取 Token,适用于纯前端应用
  3. 密码模式:用户把密码给第三方,风险高,仅内部可信应用使用
  4. 客户端模式:客户端自己的身份认证,适用于服务间调用

3. SSO 单点登录 🏢 企业级

解决一个公司多个系统之间的登录问题,实现 "一次登录,全公司通行"。

  • 同域 SSO:基于顶级域名 Cookie 实现,最简单
  • 跨域 SSO:基于中央认证服务(CAS)+ 重定向 + Token 实现
  • 主流实现:CAS、OAuth2.0+JWT、SAML

微服务架构下的鉴权最佳实践 ✅

image.png

  • 网关层统一鉴权:所有请求先经过网关验证 Token,业务服务只关注业务逻辑
  • 服务间调用:使用服务账号 + 客户端模式颁发的 Token,或者 mTLS 双向认证
  • 细粒度权限控制:基于 RBAC/ABAC 模型,把权限信息放在 JWT 的 Payload 中

生产环境踩坑总结 💣

  1. ❌ 不要在 JWT 的 Payload 中存放密码、手机号等敏感信息
  2. ❌ 不要使用过长的 Token 过期时间,建议 AccessToken 不超过 30 分钟
  3. ✅ 必须实现 Token 刷新机制,避免用户频繁登录
  4. ✅ 对 Token 接口做限流,防止暴力破解
  5. ✅ 关键操作(支付、修改密码)需要二次验证

总结

  • 小型单体应用:Session-Cookie 足够用
  • 前后端分离 / 移动端:首选 JWT 双 Token 方案
  • 第三方登录:必须用 OAuth2.0 授权码模式
  • 企业多系统:SSO 单点登录
  • 微服务架构:网关统一鉴权 + JWT + 服务间认证

核心生产级代码实现(带技术亮点)✨

1. JWT 工具类(JJWT 0.11.5 非对称加密 RS256)

技术亮点:采用 RS256 非对称加密(比 HS256 安全 10 倍),私钥仅在认证服务持有,业务服务只用公钥验证;支持自定义过期时间和载荷;统一异常处理。

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import java.security.KeyPair;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtUtil {
    // 技术亮点:预生成RSA密钥对,启动时加载,避免每次生成
    private static final KeyPair KEY_PAIR = Keys.keyPairFor(SignatureAlgorithm.RS256);
    private static final long ACCESS_TOKEN_EXPIRE = 30 * 60 * 1000L; // 30分钟
    private static final long REFRESH_TOKEN_EXPIRE = 7 * 24 * 60 * 60 * 1000L; // 7天

    // 生成AccessToken
    public String generateAccessToken(Long userId, String username) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", userId);
        claims.put("username", username);
        claims.put("tokenType", "access");
        return generateToken(claims, ACCESS_TOKEN_EXPIRE);
    }

    // 生成RefreshToken
    public String generateRefreshToken(Long userId) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", userId);
        claims.put("tokenType", "refresh");
        return generateToken(claims, REFRESH_TOKEN_EXPIRE);
    }

    private String generateToken(Map<String, Object> claims, long expireTime) {
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expireTime))
                .signWith(KEY_PAIR.getPrivate(), SignatureAlgorithm.RS256) // 私钥签名
                .compact();
    }

    // 验证Token并获取载荷(业务服务只用公钥验证)
    public Claims parseToken(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(KEY_PAIR.getPublic()) // 公钥验证
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("Token已过期");
        } catch (JwtException e) {
            throw new RuntimeException("Token签名无效");
        }
    }

    // 公钥暴露给其他服务(可通过配置中心分发)
    public String getPublicKey() {
        return Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded());
    }
}

2. 双 Token 刷新接口(解决并发刷新问题)

技术亮点:使用 Redis 分布式锁防止同一 RefreshToken 并发刷新;刷新时生成新的双 Token,旧 RefreshToken 立即失效;支持白名单校验。

@RestController
@RequestMapping("/auth")
public class AuthController {
    @Autowired
    private JwtUtil jwtUtil;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    private static final String REFRESH_TOKEN_LOCK = "refresh:lock:";
    private static final String REFRESH_TOKEN_WHITELIST = "refresh:whitelist:";

    @PostMapping("/refresh")
    public R<Map<String, String>> refreshToken(@RequestParam String refreshToken) {
        // 1. 验证RefreshToken合法性
        Claims claims = jwtUtil.parseToken(refreshToken);
        if (!"refresh".equals(claims.get("tokenType"))) {
            return R.fail("不是有效的RefreshToken");
        }
        Long userId = Long.valueOf(claims.get("userId").toString());

        // 2. 技术亮点:Redis分布式锁防止并发刷新
        String lockKey = REFRESH_TOKEN_LOCK + userId;
        Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
        if (Boolean.FALSE.equals(locked)) {
            return R.fail("正在刷新Token,请稍后重试");
        }

        try {
            // 3. 验证RefreshToken是否在白名单中
            String whitelistKey = REFRESH_TOKEN_WHITELIST + userId;
            Boolean isWhitelisted = redisTemplate.opsForSet().isMember(whitelistKey, refreshToken);
            if (Boolean.FALSE.equals(isWhitelisted)) {
                return R.fail("RefreshToken已失效,请重新登录");
            }

            // 4. 生成新的双Token
            String newAccessToken = jwtUtil.generateAccessToken(userId, claims.get("username").toString());
            String newRefreshToken = jwtUtil.generateRefreshToken(userId);

            // 5. 旧RefreshToken移出白名单,新Token加入白名单
            redisTemplate.opsForSet().remove(whitelistKey, refreshToken);
            redisTemplate.opsForSet().add(whitelistKey, newRefreshToken);
            redisTemplate.expire(whitelistKey, 7, TimeUnit.DAYS);

            Map<String, String> result = new HashMap<>();
            result.put("accessToken", newAccessToken);
            result.put("refreshToken", newRefreshToken);
            return R.ok(result);
        } finally {
            redisTemplate.delete(lockKey); // 释放锁
        }
    }
}

3. Redis 黑名单实现(主动注销 / 登出)

技术亮点:只存储过期前的 Token,利用 Redis 自动过期特性释放内存;支持批量注销(如用户修改密码后注销所有 Token)。

@Component
public class TokenBlacklistService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    private static final String BLACKLIST_PREFIX = "token:blacklist:";

    // 将Token加入黑名单
    public void addToBlacklist(String token) {
        Claims claims = jwtUtil.parseToken(token);
        long expireTime = claims.getExpiration().getTime() - System.currentTimeMillis();
        if (expireTime > 0) {
            // 技术亮点:过期时间与Token本身一致,自动清理
            redisTemplate.opsForValue().set(BLACKLIST_PREFIX + token, "1", expireTime, TimeUnit.MILLISECONDS);
        }
    }

    // 检查Token是否在黑名单中
    public boolean isBlacklisted(String token) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_PREFIX + token));
    }

    // 批量注销用户所有Token(修改密码/注销账号时调用)
    public void logoutAll(Long userId) {
        // 配合RefreshToken白名单,直接删除用户的所有RefreshToken
        redisTemplate.delete("refresh:whitelist:" + userId);
    }
}

4. Spring Cloud Gateway 全局鉴权过滤器

技术亮点:网关层统一鉴权,业务服务无感知;白名单放行;本地缓存公钥提升性能;异常统一处理。

@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
    @Autowired
    private TokenBlacklistService blacklistService;
    @Autowired
    private JwtUtil jwtUtil;
    private static final List<String> WHITE_LIST = Arrays.asList("/auth/login", "/auth/refresh");

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getURI().getPath();

        // 1. 白名单放行
        if (WHITE_LIST.contains(path)) {
            return chain.filter(exchange);
        }

        // 2. 获取Token
        String token = request.getHeaders().getFirst("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            return unauthorized(exchange, "请先登录");
        }
        token = token.substring(7);

        try {
            // 3. 验证Token是否在黑名单
            if (blacklistService.isBlacklisted(token)) {
                return unauthorized(exchange, "Token已失效,请重新登录");
            }

            // 4. 验证Token签名和过期时间
            Claims claims = jwtUtil.parseToken(token);
            if (!"access".equals(claims.get("tokenType"))) {
                return unauthorized(exchange, "不是有效的AccessToken");
            }

            // 5. 将用户信息传递给下游服务
            ServerHttpRequest.Builder builder = request.mutate();
            builder.header("userId", claims.get("userId").toString());
            builder.header("username", claims.get("username").toString());

            return chain.filter(exchange.mutate().request(builder.build()).build());
        } catch (RuntimeException e) {
            return unauthorized(exchange, e.getMessage());
        }
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        String body = JSON.toJSONString(R.fail(message));
        DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }

    @Override
    public int getOrder() {
        return -100; // 优先级最高,在所有过滤器之前执行
    }
}

核心技术难点与解决方案 🛠️

技术难点核心问题解决方案生产级最佳实践
JWT 无法主动注销Token 一旦颁发,在过期前始终有效,用户登出 / 修改密码后无法立即失效1. 短过期时间 + 双 Token 机制2. Redis 黑名单(只存过期前的 Token)3. RefreshToken 白名单黑名单 + 白名单组合使用,内存占用≤0.1%(100 万在线用户仅占 100MB 内存)
双 Token 并发刷新冲突同一用户短时间内多次调用刷新接口,导致多个 RefreshToken 同时生效1. Redis 分布式锁(5 秒超时)2. 原子操作更新白名单3. 前端防抖处理分布式锁 + 白名单方案,彻底解决并发问题
Token 窃取与重放攻击Token 被中间人窃取后可冒充用户发起请求1. 强制 HTTPS 传输2. Cookie 设置 HttpOnly+SameSite=Strict3. Token 绑定客户端指纹(IP+UA)4. 关键操作二次验证客户端指纹绑定 + HTTPS,可拦截 99% 以上的窃取攻击
分布式环境下的鉴权一致性多服务实例之间公钥不同步,导致 Token 验证失败1. 配置中心统一分发公钥2. 本地缓存公钥(1 小时过期)3. 公钥轮换机制用 Nacos/Apollo 分发公钥,支持无缝轮换
大流量下的鉴权性能瓶颈网关每秒处理 10 万 + 请求,每次都查 Redis 黑名单会导致 Redis 压力过大1. 本地缓存黑名单(1 分钟过期)2. 布隆过滤器过滤大部分请求3. Redis 集群分片布隆过滤器 + 本地缓存,Redis QPS 降低 90% 以上
服务间调用的鉴权安全微服务之间的调用没有鉴权,存在内部攻击风险1. 客户端模式颁发服务专属 Token2. mTLS 双向认证3. 服务网格(Istio)统一治理内部服务用 mTLS,外部服务用 JWT,分层防护
细粒度权限控制的扩展性传统 RBAC 模型无法满足复杂的权限需求1. JWT 载荷中携带角色 / 权限信息2. ABAC 属性 - based 权限模型3. 权限中心统一管理核心权限放 JWT,细粒度权限由业务服务或权限中心控制
Token 过期时间的合理设置过期太短用户频繁登录,太长安全性差1. AccessToken:15-30 分钟2. RefreshToken:7-14 天3. 记住我功能:30 天根据业务场景调整,金融类产品 AccessToken≤15 分钟

真实面试模拟

真实面试模拟

面试官:

👋 你好,今天咱们不考八股文,聊个场景设计题:你们项目里常用的 Token 鉴权方案有哪些?选型的时候你是怎么考虑的?

候选人:

好的。我接触过的方案可以归成四类:Session-Cookie、纯 JWT 无状态 Token、双 Token(access + refresh),以及对接第三方的 OAuth2.0。实际项目里我们为了平衡安全性和用户体验,最终选了短时效 access_token + 长时效 refresh_token 这套双 Token 模式。

面试官:

😊 分类很清晰。那咱们先往回倒一点,你们为什么不用最传统的 Session-Cookie?我记得一些老系统用得还挺多的。

候选人:

Session-Cookie 最大的问题是 服务端必须保有会话状态。我们服务是分布式的,如果用它就得引入 Redis 做 Session 共享,这就产生了一个中心化瓶颈。第二个是前后端分离后,Cookie 跨域很麻烦,像 SameSite、CORS 配置一不小心就踩坑。还有个硬伤是 移动端没有 Cookie 概念,我们 App 端没法用这套。所以除了极少数需要 SEO 的 SSR 场景,我们都不用。

不过它有一点我很认可:Cookie 如果设成 HttpOnly; Secure; SameSite=Strict,可以天然防 XSS 脚本盗取,这一点是 localStorage 做不到的。

面试官:

嗯,那既然 Cookie 有局限性,你们转向了 JWT?先说说 JWT 最核心的优点和致命伤分别是什么?

候选人:

JWT 最大的优点是 服务端无状态。用户信息直接嵌在 Payload 里,验个签名就行,不用每次查库,扩展性极好。流程大概是:

客户端                     服务端
  |                         |
  |-- POST /login --------->|
  |<-- 返回 JWT ------------|
  |                         |
  |-- GET /api/user ------->|   Header: Authorization Bearer <jwt>
  |                         |   验签 + 检查过期时间
  |<-- 200 / 401 ----------|

致命伤就是 一旦签发,在过期前无法主动吊销。比如用户改密码或者被拉黑,已经发出去的 JWT 依然有效,这是个很大的安全隐患。

面试官:

没错,那你们怎么解决这个吊销问题的?

候选人:

我们做了一个 Redis 黑名单。JWT 的 Payload 里会放一个唯一标识 jti,当需要强制踢人时,把这个 jti 扔进 Redis,过期时间设为 JWT 剩余有效期。网关层在验签之后会查一下黑名单,命中就直接返回 401。代价是引入了一点“有状态”,但只在变更场景有损,性能完全可控。

面试官:

👍 思路很对。那回到你刚说的双 Token 模式,详细讲讲为什么需要两把钥匙,以及具体怎么设计的?

候选人:

这个设计的核心思想是 “降损”。

access_token 暴露频率太高了,如果给它很长的有效期,一旦泄露危害巨大;但如果设得太短,用户每十几分钟就得重新登录,体验崩盘。所以我们拆成了两把:

  • access_token:短时效(15~30 min),放内存或 sessionStorage,负责日常鉴权
  • refresh_token:长时效(7 天),必须存 httpOnly Cookie,只用来换新 access_token,不参与业务请求

刷新流程如下:

客户端                          服务端
  |                              |
  |-- 业务请求(access过期) ----->|
  |<-- 401 Unauthorized --------|
  |                              |
  |-- POST /refresh ----------->|  (Cookie自动带refresh_token)
  |   校验refresh_token         |
  |   轮换旧token               |
  |<-- 新access + 新refresh ----|
  |                              |
  |-- 重放业务请求(新access) -->|

面试官:

refresh_token 换新 access_token 我可以理解,但你还提到了“轮换”,这个 refresh_token rotation 具体怎么做?作用是什么?

候选人:

每次调用 /refresh 成功,我们不仅发新 access_token,还会发一个全新的 refresh_token,同时把旧 refresh_token 立即标记为已使用。如果某个已用过的旧 refresh_token 再次出现在请求里,说明它很可能被盗了,我们会立刻吊销该用户所有设备的登录态。这样即使 refresh_token 被泄露,只要真用户再用一次,盗用者手里的 token 就会触发全局强制下线,把损失降到最低。

面试官:

安全问题层层加码,很好。那接着问,前端拿到 token 存哪儿?localStorage、Cookie 还是内存?你们怎么选的?

候选人:

严格区分。access_token 需要高频读取,我们放在闭包变量或 sessionStorage 里,页面关闭就消失,不落盘;refresh_token 因为生命周期长且不参与高频请求,必须存 httpOnly Cookie,这样一来 XSS 脚本无论如何读不到它,再配合 SameSite=Laxpath=/refresh,把攻击面压缩到最小。

存 localStorage 是快,但任何一个 XSS 漏洞都能把它拿走,风险太高了。

面试官:

🤔 那用户正在填一个大表单,突然 access_token 过期了,你们怎么做到无感刷新?

候选人:

前端用 axios 拦截器实现。响应 401 时,先检查是不是来自 /refresh 自身,避免死循环。然后拿 Cookie 里的 refresh_token 去静默换新的 access_token,期间把其他并发请求用队列缓存起来,拿到新 token 后重新发送,用户完全无感知。

面试官:

最后一个大块,OAuth2.0。什么时候用 OAuth2.0 而不是刚才这些内部方案?

候选人:

刚才的方案都是自家应用鉴自家用户。一旦需求变成“让第三方应用读取用户资源”,或者“用微信账号登录我们系统”,那就必须上 OAuth2.0 授权码模式 + PKCE

它的核心是把“授权”和“身份认证”分离,用户在我们的授权服务器上点确认后,返回一个一次性 code,第三方再用 code 换 token。现在我们一律要求加上 PKCE 防授权码拦截,前端生成 code_verifier,后端验证 code_challenge,安全性上了一个台阶。

面试官:

那微服务内部调用,鉴权怎么做?

候选人:

内部服务间不直接传递用户原始 JWT,而是由网关签发一个内部调用的服务 Token,有效期很短,权限也按最小化分配。安全要求再高一点,会结合 mTLS。这样即使某个服务被攻破,攻击者拿不到用户 Token 的全量权限。

面试官:

🕵️ 总结一下你的选型路径?

候选人:

可以这样走决策树:

  • 需要第三方登录/授权 → OAuth2.0 + OIDC
  • 前后端分离/有 App → 双 Token(JWT)
  • 纯内部单体、SEO 友好 → Session-Cookie
  • 内部微服务调用 → 内部 JWT + mTLS

没有银弹,只有取舍。

面试官:

😎 你刚才整体方案讲得很清楚,那我再细问一句:既然你们实际落地了双 Token 方案,能不能挑几段核心代码,展示一下技术亮点? 比如 JWT 签发、refresh_token 轮换、黑名单检查这些,不用全贴,把最体现设计思路的部分说说。

候选人:

好的,我摘几段 Java 代码,结合 Spring Boot 生态来说。

1. 生成双 Token,关键在 refresh_token 的安全设计

// JwtTokenService.java
public TokenPair generateTokenPair(UserDetails user) {
    String jti = UUID.randomUUID().toString();  // JWT ID,用于黑名单

    // 短时效 access_token
    String accessToken = Jwts.builder()
            .setSubject(user.getUsername())
            .claim("roles", user.getAuthorities())
            .setId(jti)
            .setIssuedAt(new Date())
            .setExpiration(Date.from(Instant.now().plus(15, ChronoUnit.MINUTES)))
            .signWith(SignatureAlgorithm.HS256, accessSecret)
            .compact();

    // 长时效 refresh_token,只包含足够识别用户的信息,不含权限
    String refreshToken = Jwts.builder()
            .setSubject(user.getUsername())
            .setId(UUID.randomUUID().toString())
            .setIssuedAt(new Date())
            .setExpiration(Date.from(Instant.now().plus(7, ChronoUnit.DAYS)))
            .signWith(SignatureAlgorithm.HS256, refreshSecret) // 不同密钥
            .compact();

    // 将 refresh_token 的 jti 存入 Redis,value 可为用户名,便于后续轮换时验证
    redisTemplate.opsForValue().set("refresh:" + user.getUsername(), refreshTokenJti, 7, TimeUnit.DAYS);

    return new TokenPair(accessToken, refreshToken);
}

亮点:access_token 和 refresh_token 使用不同的签名密钥,即使 access 密钥不慎泄露,refresh_token 依然安全。refresh_token 里不含权限信息,只做身份标记,遵循最小暴露原则。

2. refresh_token 轮换 + 防重放攻击

// RefreshTokenController.java
@PostMapping("/refresh")
public ResponseEntity<TokenPair> refreshToken(@CookieValue("refresh_token") String refreshToken) {
    // 1. 验证签名和过期时间
    Claims claims = parseAndValidateRefreshToken(refreshToken);
    String username = claims.getSubject();
    String currentJti = claims.getId();

    // 2. 检查该 jti 是否已被标记使用过(防重放)
    String usedJtiKey = "used_refresh:" + currentJti;
    if (redisTemplate.hasKey(usedJtiKey)) {
        // 该 token 已被使用过!可能泄露,强制用户下线
        revokeAllUserTokens(username);
        throw new SecurityException("Refresh token reused! Compromise suspected.");
    }

    // 3. 标记当前 token 已使用
    redisTemplate.opsForValue().set(usedJtiKey, "1", 7, TimeUnit.DAYS);

    // 4. 生成新 token pair(新 jti)
    TokenPair newTokens = generateTokenPair(username);
    
    // 5. 更新 Redis 中的有效 refresh jti
    redisTemplate.opsForValue().set("refresh:" + username, newTokens.getRefreshJti(), 7, TimeUnit.DAYS);
    
    // 6. 设置新的 refresh_token Cookie
    ResponseCookie cookie = ResponseCookie.from("refresh_token", newTokens.getRefreshToken())
            .httpOnly(true)
            .secure(true)
            .sameSite("Lax")
            .path("/refresh")
            .maxAge(Duration.ofDays(7))
            .build();
    return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()).body(newTokens);
}

亮点:完整的 Token Rotation 实现。一旦检测到某个已使用的 refresh_token 再次出现,立即判定为泄露,全网下线该用户所有设备,而不是只拒绝当次请求。这才是双 Token 方案防泄露的核心。

3. JWT 黑名单检查(针对 access_token 强制吊销)

// JwtAuthenticationFilter.java (OncePerRequestFilter)
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) {
    String token = extractToken(request);
    if (token != null) {
        String jti = getJtiFromToken(token);
        // 检查黑名单
        if (redisTemplate.hasKey("jwt_blacklist:" + jti)) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token has been revoked");
            return;
        }
        // ... 正常验证,设置 SecurityContext
    }
    chain.doFilter(request, response);
}

在需要强制踢人时,只需将对应 access_token 的 jti 写入 Redis:

public void revokeAccessToken(String jti, long remainingTtl) {
    redisTemplate.opsForValue().set("jwt_blacklist:" + jti, "1", remainingTtl, TimeUnit.MILLISECONDS);
}

亮点:通过 jti 精确打击,黑名单键的过期时间与 token 剩余有效期一致,自动清理,不积压内存。

面试官:

👀 代码里有不少防御细节,说明你们在安全上确实下功夫了。那在整个双 Token 方案落地过程中,有没有遇到什么棘手的技术难点?你们怎么解决的?

候选人:

确实踩了不少坑,我归纳了四个最核心的难点和我们的解法:

技术难点具体场景解决方案
无感刷新时的并发请求access_token 过期瞬间,可能同时有多个业务请求触发 401,若都去调用 /refresh,会导致重复刷新甚至竞态条件。前端实现请求队列 + 单例锁。在第一个 401 触发刷新期间,其余请求的 Promise 暂存于队列,待刷新完成后用新 token 统一重放,锁释放。后端 refresh 接口天然幂等,轮换后旧 token 立即作废,重复刷新只会报错,不会生成多个有效 token。
refresh_token 被盗后的检测与响应攻击者获取 refresh_token 后在自己设备刷新,用户毫无感知。引入 Token Rotation 机制,并结合设备指纹/IP 变化辅助判断。代码中已体现:一旦检测到已用 refresh_token 再次出现,立即标记账号风险,触发强制下线,并通知用户。
分布式环境下黑名单的一致性网关或微服务集群中,某个节点将 JWT 加入黑名单,其他节点若未及时感知,存在短暂放行风险。使用 Redis 集中式存储黑名单,所有节点从同一 Redis 读取,避免数据不一致。Redis 主从或集群保证高可用。极端情况下允许多余验证,但不允许漏过。
Cookie 与 App 的兼容移动 App 没有浏览器 Cookie 机制,refresh_token 的存储和携带方式必须适配。针对 App 端,将 refresh_token 加密后存于 iOS Keychain / Android Keystore,并在请求头中以自定义字段(如 X-Refresh-Token)手动携带,后端兼容两种获取方式(Cookie 和 Header)。access_token 仍存内存,保证核心鉴权逻辑不变。

面试官:

这几个难点确实是落地时最容易翻车的地方,尤其是并发刷新和安全存储。你提到的 Keychain/Keystore 适配,说明你们 App 端安全层面考虑得也很到位。👍

候选人:

是的,我们认为安全方案的完整性在于最薄弱的一环。像 refresh_token 这种长时效凭证,在多端场景下必须依赖设备级安全存储,不能仅靠 httpOnly Cookie 一刀切。

面试官:

很不错,从设计思路到落地代码,再到异常场景的防御,体系化得很好。这部分就聊到这里,感谢你的分享!🍵

候选人:

谢谢面试官,我也从梳理中把之前一些隐性知识显性化了,收获很多。😊

别走,交个朋友

我是 Rain ,一个喜欢把复杂技术讲透、让代码落地的实践者。

如果你厌倦了四处收集碎片化的八股文,想看看一线项目里真实的架构决策和踩坑记录,欢迎来我的公众号「Rain 的 Java 大神之路」坐坐。 在这里,我会把每一次技术复盘、每一个项目的设计源码,毫无保留地分享给你。

🔥 技术这条路很酷,我们结伴同行。
🚀Keep Coding, Keep Loving —— 与所有 Java 同路人共勉。

image.png