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

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

整体认知

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

主流鉴权方案对比 📊

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

各方案核心详解

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

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

  • 优点:无状态,服务器不需要存储,天然支持分布式;跨语言;轻量
  • 缺点: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

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

  • 网关层统一鉴权:所有请求先经过网关验证 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 + 服务间认证

关于这个问题的底层原理和更多实战细节,我整理了一份《大厂面试手册》,包含大厂高频面试题、源码解析和性能调优案例。

关注公众号【Rain的Java大神之路】,回复"Java"即可免费领取,持续更新中。


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

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

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

java 复制代码
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 立即失效;支持白名单校验。

java 复制代码
@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)。

java 复制代码
@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 全局鉴权过滤器

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

java 复制代码
@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 里,验个签名就行,不用每次查库,扩展性极好。流程大概是:

text 复制代码
客户端                     服务端
  |                         |
  |-- 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,不参与业务请求

刷新流程如下:

text 复制代码
客户端                          服务端
  |                              |
  |-- 业务请求(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 的安全设计

java 复制代码
// 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 轮换 + 防重放攻击

java 复制代码
// 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 强制吊销)

java 复制代码
// 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:

java 复制代码
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的Java大神之路】。

专注 Java 面试、源码、高并发实战,回复"Java"领取《大厂面试手册》,持续更新。

相关推荐
Rain的Java大神实战圈12 小时前
高并发下的抢红包设计:微信红包背后的算法与温情
场景设计题
Rain的Java大神实战圈2 天前
如何设计对外API接口
场景设计题
Rain的Java大神实战圈5 天前
如何保证接口幂等
场景设计题
Rain的Java大神实战圈6 天前
PC版网站被狂刷怎么处理
场景设计题
Rain的Java大神实战圈7 天前
接口被狂刷怎么处理
场景设计题
Rain的Java大神实战圈8 天前
线上在Docker中跑MySQL会怎样
场景设计题
Rain的Java大神实战圈9 天前
Docker搭建Redis集群完全指南
场景设计题
Rain的Java大神实战圈12 天前
短信接口被狂刷怎么处理
场景设计题
Rain的Java大神实战圈13 天前
如何避免订单重复提交
场景设计题