介绍一下常用的Token鉴权方案
介绍一下常用的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 🔑 第三方授权
核心思想是 "授权而非认证",让第三方应用在不获取用户密码的情况下访问用户资源。
四种授权模式:
- 授权码模式(最安全,推荐):先获取授权码,再用授权码换 Token
- 简化模式:前端直接获取 Token,适用于纯前端应用
- 密码模式:用户把密码给第三方,风险高,仅内部可信应用使用
- 客户端模式:客户端自己的身份认证,适用于服务间调用
3. SSO 单点登录 🏢 企业级
解决一个公司多个系统之间的登录问题,实现 "一次登录,全公司通行"。
- 同域 SSO:基于顶级域名 Cookie 实现,最简单
- 跨域 SSO:基于中央认证服务(CAS)+ 重定向 + Token 实现
- 主流实现:CAS、OAuth2.0+JWT、SAML
微服务架构下的鉴权最佳实践 ✅
- 网关层统一鉴权:所有请求先经过网关验证 Token,业务服务只关注业务逻辑
- 服务间调用:使用服务账号 + 客户端模式颁发的 Token,或者 mTLS 双向认证
- 细粒度权限控制:基于 RBAC/ABAC 模型,把权限信息放在 JWT 的 Payload 中
生产环境踩坑总结 💣
- ❌ 不要在 JWT 的 Payload 中存放密码、手机号等敏感信息
- ❌ 不要使用过长的 Token 过期时间,建议 AccessToken 不超过 30 分钟
- ✅ 必须实现 Token 刷新机制,避免用户频繁登录
- ✅ 对 Token 接口做限流,防止暴力破解
- ✅ 关键操作(支付、修改密码)需要二次验证
总结
- 小型单体应用: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. 客户端模式颁发服务专属 Token 2. 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=Lax、path=/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 一刀切。
面试官:
很不错,从设计思路到落地代码,再到异常场景的防御,体系化得很好。这部分就聊到这里,感谢你的分享!🍵
候选人:
谢谢面试官,我也从梳理中把之前一些隐性知识显性化了,收获很多。😊
