大家好,我是程序员小策。
场景:你和前端联调一个登录功能,本地跑得好好的,部署到测试环境就频繁跳登录页。排查了半天,发现是两台应用服务器之间 Session 没同步。你加了个 Redis 共享 Session,问题解决了。
过了两个月,项目要接入 App。前端说 App 不支持 Cookie。你又加了个 Token 认证。又过了三个月,安全审计说 Token 过期时间太长有风险,你改短了过期时间。然后用户开始反馈"用着用着就要重新登录"。
你陷入了沉思------这认证方案到底该怎么设计?
这不是段子。这是我亲眼见过的真实故事,而且不止一次。
今天这篇文章,我们就来把这个过程完整走一遍。从最朴素的 Cookie 开始,一步步演进到企业级双令牌 JWT 架构。每一步解决什么问题、引入了什么新问题、为什么下一步是必然,我都会给你讲清楚。
第一代:Cookie------无状态的"有状态"方案
你可能觉得 Cookie 太古老了。但它的设计思想直到今天都在用。
Cookie 是服务器下发并存储在客户端的小型数据片段,由浏览器自动在每次请求时携带,用于维持请求之间的状态。
这句话的关键词是"自动携带"。一个 HTTP 请求从诞生起就是无状态的------服务器处理完请求就忘了你。Cookie 的发明,让服务器有了"认人"的能力。
写一个登录逻辑,最原始的方式是这样的:
java
// 登录成功后,在服务端生成一个标识,通过 Cookie 下发给浏览器
@PostMapping("/login")
public String login(String username, String password, HttpServletResponse response) {
// 验证用户名密码(简化处理,生产请用 BCrypt)
if ("admin".equals(username) && "123456".equals(password)) {
// 创建一个简单的 Token:用户名 + 时间戳 + 简单签名
// ★ 注意:这只是演示!生产绝不能自己拼签名,要用标准算法
String rawToken = username + ":" + System.currentTimeMillis();
String cookieValue = rawToken + ":" + md5(rawToken + "secret");
// 设置 Cookie:HttpOnly 防止 JS 读取,path=/ 全局生效
Cookie cookie = new Cookie("AUTH_TOKEN", cookieValue);
cookie.setHttpOnly(true); // 防 XSS 攻击窃取 Cookie
cookie.setPath("/");
response.addCookie(cookie);
return "login success";
}
return "login failed";
}
这个方案的问题很明显------Token 里只有用户名和时间戳,没有任何安全机制。只要有人截获了你的 Cookie,他就能伪造身份。而且这个 Token 是永不过期的。
所以很快它就进化成了 Session。
第二代:Session------把"令牌"变成"房卡"
Session 的核心变化,就是把身份信息从客户端挪到了服务端。
java
// Session 方案:登录信息存在服务器内存中
@PostMapping("/login")
public String login(String username, String password, HttpServletRequest request) {
if ("admin".equals(username) && "123456".equals(password)) {
// 创建 Session,将用户信息存入服务器
HttpSession session = request.getSession();
session.setAttribute("userId", 1001);
session.setAttribute("username", username);
// Session ID 会通过 Cookie 自动返回给浏览器(JSESSIONID)
// 服务器内存中维护着 Session ID -> Session 数据的映射
return "login success";
}
return "login failed";
}
// 从 Session 获取当前用户
@GetMapping("/profile")
public String profile(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return "not logged in";
}
String username = (String) session.getAttribute("username");
return "hello, " + username;
}
Session 方案把"令牌验证"变成了"房卡开门"------你拿着房卡(Session ID)去前台(服务器),前台翻一下登记本(Session 存储)就知道你是谁。
单机部署没问题。但分布式下,问题就来了
用户的两次请求可能落在不同的服务器上。Server 1 有你的 Session,Server 2 没有。
解决方案也很成熟:用 Redis 集中存储 Session。
java
// 引入 Spring Session + Redis,一行注解解决分布式 Session 问题
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class RedisSessionConfig {
// maxInactiveIntervalInSeconds = 1800 表示 Session 30 分钟过期
// Spring Session 会自动将 Session 存储从内存切换到 Redis
// 所有应用服务器共享同一个 Redis,Session 不再丢失
}
但 Session 方案有一个硬伤------它是有状态的。每个请求都要查一次 Redis。Redis 挂了,认证系统也挂了。而且移动端天然不支持 Cookie,你需要额外适配 Token。
这时候,JWT 出现了。
第三代:JWT 单令牌------从"查我"变成"验我"
JWT 的核心思想:不在服务端存任何状态,把认证信息直接编码进 Token 里,服务端只验签名。
JWT(JSON Web Token)是一种自包含的、紧凑的 Token 格式,它将用户身份和权限信息加密签名在 Token 本身中,服务器无需查询任何存储就能验证用户身份。
这就是"无状态"认证------你的请求落到集群里的哪台机器上都行,每台机器自己就能验证。
JwtProvider 是核心,负责两件事:签发和验证 Token。
java
package com.hendisantika.springbootjwtauthentication.jwt;
import io.jsonwebtoken.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* JWT Token 的核心工具类
* 负责两件事:签发 Token(登录成功后调用)、验证 Token(每次请求时调用)
*/
@Component
public class JwtProvider {
private static final Logger logger = LoggerFactory.getLogger(JwtProvider.class);
// 签名密钥:从配置文件中注入,用于签发和验证 Token 的签名
// ★ 生产环境绝对不能硬编码!必须通过配置中心或环境变量注入
@Value("${app.jwtSecret}")
private String jwtSecret;
// Token 过期时间:单位毫秒,比如 86400000 表示 24 小时
@Value("${app.jwtExpiration}")
private int jwtExpiration;
/**
* 生成 JWT Token
* 用户登录成功后调用,将用户身份编码进 Token
*/
public String generateJwtToken(Authentication authentication) {
UserPrinciple userPrincipal = (UserPrinciple) authentication.getPrincipal();
return Jwts.builder()
// setSubject: 将用户名作为 Token 主题(后续解析时从这里拿用户身份)
.setSubject(userPrincipal.getUsername())
// setIssuedAt: 签发时间
.setIssuedAt(new Date())
// setExpiration: 过期时间 = 当前时间 + 配置时长
.setExpiration(new Date(new Date().getTime() + jwtExpiration))
// signWith: 用 HS512 算法 + 密钥签名,防止 Token 被篡改
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
/**
* 从 Token 中解析出用户名
* 每次请求过滤器时调用,获取当前操作用户的身份
*/
public String getUserNameFromJwtToken(String token) {
return Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody().getSubject();
}
/**
* 验证 Token 是否合法
* 返回 true 表示有效,false 表示 Token 有问题
*/
public boolean validateJwtToken(String authToken) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
return true;
} catch (SignatureException e) {
// 签名不匹配 → Token 被篡改
logger.error("Invalid JWT signature: {}", e.getMessage());
} catch (ExpiredJwtException e) {
// Token 过期
logger.error("Expired JWT token: {}", e.getMessage());
} catch (MalformedJwtException e) {
// Token 格式错误
logger.error("Invalid JWT token: {}", e.getMessage());
}
return false;
}
}
JwtAuthTokenFilter 是拦截每个请求的"安检闸机":
java
package com.hendisantika.springbootjwtauthentication.jwt;
import com.hendisantika.springbootjwtauthentication.service.UserDetailsServiceImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* JWT 认证过滤器:每个请求经过时,从请求头提取 Token → 验证 → 注入安全上下文
* 继承 OncePerRequestFilter 确保每个请求只过滤一次
*/
public class JwtAuthTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtProvider tokenProvider;
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) {
try {
// 第一步:从 Authorization 请求头中提取 JWT Token
String jwt = getJwtFromRequest(request);
// 第二步:Token 存在且验证通过,则解析用户信息并注入安全上下文
if (jwt != null && tokenProvider.validateJwtToken(jwt)) {
String username = tokenProvider.getUserNameFromJwtToken(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
// 将认证信息注入 SecurityContext
// ★ 这一步之后,Controller 中就能通过 @AuthenticationPrincipal 获取当前用户
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.error("Can NOT set user authentication: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
/**
* 从请求头中提取 Token
* 标准格式:Authorization: Bearer <token>
*/
private String getJwtFromRequest(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.replace("Bearer ", "");
}
return null;
}
}
WebSecurityConfig 是 Spring Security 的配置------这里有一个关键配置:
java
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors().and().csrf().disable()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll() // 登录注册放行
.anyRequest().authenticated()
.and()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler)
.and()
// ★ 核心:完全关闭 Session 管理
// 告诉 Spring Security:不要创建 HttpSession,也不要从 Session 中恢复认证
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// ★ 将 JWT 过滤器插入到 UsernamePasswordAuthenticationFilter 之前
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
}
单令牌 JWT 解决了 Session 的分布式问题,但又引入了一个新问题
Token 无法主动失效。
Session 方案里,你从 Redis 删掉 Session,用户立即被踢下线。但 JWT 一旦签发,在过期之前都是有效的。你改密码了、用户被管理员封禁了------对不起,只要 Token 没过期,他还能继续操作。
怎么办?时间设短一点?
设 15 分钟,用户体验差------用着用着就过期了。设 24 小时,安全风险大------泄露了能存活一天。
于是,企业级方案出现了。
第四代:双令牌 JWT------Access Token + Refresh Token
双令牌架构将认证拆分为两个 Token:Access Token(短时效,用于实际认证)和 Refresh Token(长时效,专门用于续签 Access Token),在安全性和体验之间找到了平衡。
Access Token 相当于你去游乐园玩项目时拿的"快速通行券"------有效期短(15-30分钟),丢失了危害有限。
Refresh Token 相当于你的"年卡"------有效期长(7-30天),平时收在保险柜里,只在需要换通行券时才拿出来用。
它完美解决了前三个时代的所有痛点:
| 痛点 | 解决方式 |
|---|---|
| Session 需要集中存储(有状态) | Access Token 纯无状态,不需要任何服务端存储 |
| JWT 单令牌无法主动失效 | Access Token 过期短(15分钟),退出登录时删掉 Refresh Token 的存储记录,Refresh Token 失效后 Access Token 很快自动过期 |
| JWT 过期时间短则体验差 | Access Token 短(安全),Refresh Token 长(体验),两者配合兼顾 |
| 移动端不支持 Cookie | 双令牌都通过 HTTP Header 传递,天然跨平台 |
下面这套代码来自 GitHub 上的企业级项目 MTVS-3rdBE 的 JWT 双令牌实现,我加上了详细的中文注释:
java
package com.mtvs.mtvs3rdbe.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
/**
* 双令牌 JWT 提供者
*
* 设计理念:
* - Access Token:有效期 15 分钟,携带用户身份和权限信息,用于 API 认证
* - Refresh Token:有效期 3 天,仅包含"类型"标识,专门用于续签 Access Token
* - 两个 Token 使用相同的密钥签名,但通过 type 字段区分用途
*/
@Slf4j
@Component
public class JWTTokenProvider {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String AUTHORITIES_KEY = "auth"; // 权限信息的 Claims Key
private static final String BEARER_TYPE = "Bearer";
private static final String TYPE_ACCESS = "access"; // Access Token 类型标识
private static final String TYPE_REFRESH = "refresh"; // Refresh Token 类型标识
private static final String CLAIM_TYPE = "type"; // Token 类型 Claims Key
// 签名密钥,Base64 解码后生成 HMAC-SHA 密钥
private final Key secretKey;
// Access Token 有效期:1 小时(注释写 15 分钟,可根据实际情况调整)
private static final long ACCESS_TOKEN_LIFETIME = 60 * 60 * 1000L;
// Refresh Token 有效期:3 天
private static final long REFRESH_TOKEN_LIFETIME = 3 * 24 * 60 * 60 * 1000L;
public JWTTokenProvider(@Value("${JWT.SECRET}") String secretKey) {
// ★ 使用 Decoders.BASE64 而不是直接 getBytes()
// 因为密钥是 Base64 编码的字符串,直接 getBytes 会得到完全不同的字节数组
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
}
/**
* 生成双令牌(同时签发 Access Token 和 Refresh Token)
*
* 这是核心方法,在用户登录成功后调用
* 返回两个 Token:
* - accessToken:短时效,携带完整的用户信息(用户名 + 权限)
* - refreshToken:长时效,仅携带 type=refresh 标识
*/
public UserResponseDTO.authTokenDTO generateToken(Authentication authentication) {
// 从认证信息中提取用户名和权限列表
String name = authentication.getName();
Collection<? extends GrantedAuthority> grantedAuthorities = authentication.getAuthorities();
// 将权限列表拼接成逗号分隔的字符串(如 "ROLE_USER,ROLE_ADMIN")
String authorities = grantedAuthorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Date now = new Date();
// ---- 签发 Access Token ----
// ★ Access Token 包含完整的用户身份和权限信息
// 这样下游服务拿到 Token 后不需要再查数据库就能知道用户是谁、能做什么
String accessToken = Jwts.builder()
.setSubject(name) // 用户名
.claim(AUTHORITIES_KEY, authorities) // 权限列表
.claim(CLAIM_TYPE, TYPE_ACCESS) // 标记为 Access Token
.setIssuedAt(now) // 签发时间
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_LIFETIME)) // 15分钟后过期
.signWith(secretKey, SignatureAlgorithm.HS256) // HS256 签名
.compact();
// ---- 签发 Refresh Token ----
// ★ Refresh Token 只有 type 和过期时间,没有用户身份信息
// 因为它只用来"续" Access Token,不需要知道具体是谁
String refreshToken = Jwts.builder()
.claim(CLAIM_TYPE, TYPE_REFRESH) // 标记为 Refresh Token
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_LIFETIME)) // 3天后过期
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
// 返回双令牌,客户端需要保存两个 Token
return new UserResponseDTO.authTokenDTO(
BEARER_TYPE, // "Bearer"
accessToken, // 短时效 Access Token
ACCESS_TOKEN_LIFETIME, // 有效期(毫秒)
refreshToken, // 长时效 Refresh Token
REFRESH_TOKEN_LIFETIME // 有效期(毫秒)
);
}
/**
* 从 Token 中解析出 Spring Security 的 Authentication 对象
* 过滤器拿到 Access Token 后调用此方法,还原用户身份
*/
public Authentication getAuthentication(String token) {
// 解析 Token 的 Claims(Payload 部分)
Claims claims = parseClaims(token);
// 没有权限信息 → 拒绝
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("Token has no authority info");
}
// 从 Claims 中还原权限列表
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// 创建 UserDetails 对象(Spring Security 内置的 User 类)
UserDetails principal = new User(claims.getSubject(), "", authorities);
// 返回 Authentication 对象,交给 SecurityContextHolder
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
/**
* 验证 Token 有效性
* 区分不同类型的异常,方便排查问题
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
log.info("Invalid JWT signature: {}", e.getMessage());
} catch (ExpiredJwtException e) {
log.info("Expired JWT token: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT token: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.info("JWT claims is empty: {}", e.getMessage());
}
return false;
}
/**
* 判断 Token 是否是 Refresh Token
* 在 /api/auth/reissue 接口中用到------只有 Refresh Token 才能换新的 Access Token
*/
public boolean isRefreshToken(String token) {
String type = (String) Jwts.parserBuilder()
.setSigningKey(secretKey).build()
.parseClaimsJws(token).getBody().get(CLAIM_TYPE);
return TYPE_REFRESH.equals(type);
}
/**
* 从 HTTP 请求头中提取 Token
* 标准格式:Authorization: Bearer <token>
*/
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
return bearerToken.substring(7); // 去掉 "Bearer " 前缀
}
return null;
}
}
过滤器中的关键逻辑:对 /api/auth/reissue(续签接口)和 /api/auth/logout(登出接口)跳过认证,因为这两个接口本身就是用来处理 Token 的。
java
package com.mtvs.mtvs3rdbe.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;
import java.io.IOException;
/**
* 双令牌认证过滤器
*
* 处理逻辑与单令牌 JWT 的区别:
* 1. 只验证 Access Token,不验证 Refresh Token
* 2. 对 /api/auth/reissue 路径放行(这个接口内部用 Refresh Token 换 Access Token)
* 3. 对 /api/auth/logout 路径放行(登出只需要找到并删除 Refresh Token 的存储)
*/
@RequiredArgsConstructor
@Slf4j
public class JWTTokenFilter extends GenericFilterBean {
private final JWTTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
String token = jwtTokenProvider.resolveToken((HttpServletRequest) servletRequest);
String requestURI = ((HttpServletRequest) servletRequest).getRequestURI();
// Token 不为空且验证通过
if (token != null && jwtTokenProvider.validateToken(token)) {
// ★ 对续签和登出接口不做认证注入(这两个接口本身就在处理认证流程)
if (!"/api/auth/reissue".equals(requestURI)
&& !"/api/auth/logout".equals(requestURI)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("User {} authenticated", authentication.getName());
}
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
续签接口的实现逻辑(伪代码):
java
@PostMapping("/api/auth/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request) {
String refreshToken = jwtTokenProvider.resolveToken(request);
// 1. 验证 Refresh Token 是否有效
if (!jwtTokenProvider.validateToken(refreshToken)) {
return ResponseEntity.status(401).body("Refresh Token expired");
}
// 2. 确认是 Refresh Token(不是 Access Token 伪装的)
if (!jwtTokenProvider.isRefreshToken(refreshToken)) {
return ResponseEntity.status(401).body("Not a refresh token");
}
// 3. (可选)检查 Redis 黑名单:如果 Refresh Token 已被注销则拒绝
if (redisTemplate.hasKey("blacklist:" + refreshToken)) {
return ResponseEntity.status(401).body("Refresh Token has been revoked");
}
// 4. 签发新的 Access Token(不需要重新登录!)
Authentication authentication = jwtTokenProvider.getAuthentication(refreshToken);
String newAccessToken = jwtTokenProvider.generateToken(authentication).getAccessToken();
return ResponseEntity.ok(new JwtResponse(newAccessToken));
}
这些生产陷阱,你应该心里有数
陷阱一:Access Token 时间设多长最合适?
没有标准答案,取决于你的业务安全等级。一般建议:
- 金融/支付类:5-10 分钟
- 通用业务类:15-30 分钟
- 低敏感类:1-2 小时
重要的是:Access Token 的时间决定了"用户被封禁后多久才能真正失效"。 设 5 分钟,最多忍受 5 分钟的安全窗口。
陷阱二:Refresh Token 的存储必须安全
Refresh Token 是用户的"长期通行证"。浏览器端必须存 HttpOnly Cookie(防 XSS),移动端必须存安全存储区(iOS Keychain / Android EncryptedSharedPreferences)。绝对不要存 localStorage。
陷阱三:退出登录需要做两件事
- 客户端删除 Access Token 和 Refresh Token
- 服务端将 Refresh Token 加入黑名单(Redis Set 或 DB 标记)
如果不做第二步,Refresh Token 仍然是有效的,任何人都可以用它续签新的 Access Token。典型的做法是在 Redis 中维护一个"已注销 Refresh Token 集合",续签接口先查这个集合。
陷阱四:续签接口要防重放攻击
如果有人截获了你的 Refresh Token,他可以反复调用续签接口获取新的 Access Token。解法:Refresh Token 用一次就作废(轮替机制),每次续签时签发新的 Refresh Token,旧的立即失效。
四个时代的方案对比
| 方案 | 存储位置 | 状态 | 移动端支持 | 可主动失效 | 分布式友好 | 适用场景 |
|---|---|---|---|---|---|---|
| Cookie 原生 | 客户端 | 无状态 | 不支持 | 否 | 是 | 极简场景,基本被淘汰 |
| Session + Redis | 服务端 Redis | 有状态 | 需额外适配 | 是 | 需共享 Redis | 单体/中小规模分布式 |
| JWT 单令牌 | Token 本身 | 无状态 | 原生支持 | 否 | 是 | 微服务、前后端分离 |
| 双令牌 JWT | Token 本身 + Redis(黑名单) | 无状态+轻量状态 | 原生支持 | 是(黑名单机制) | 是 | 企业级生产首选 |
一句话选型:做 Demo 用单令牌 JWT 够了,上生产直接上双令牌架构------安全性和用户体验不用二选一。
面试追问
面试追问 1:双令牌架构下,Access Token 过期后客户端怎么知道要去续签?
两种方案:1)前端在 HTTP 拦截器中检测 401 状态码,自动调用续签接口重试;2)Access Token 过期前几分钟,前端根据 expires_in 字段提前续签。方案 1 更可靠,方案 2 体验更好(无感知刷新)。
面试追问 2:为什么双令牌架构中,续签接口不能用 Access Token 而必须用 Refresh Token?
Access Token 有效期短,如果允许用 Access Token 续签,攻击者在 15 分钟内截获了 Token,就可以无限续期,等于 Token 永不过期。Refresh Token 需要更高级别的保护(服务端黑名单 + 短生命周期)。
面试追问 3:如果 Redis 集群挂了,双令牌架构的"黑名单"机制失效,怎么兜底?
方案一:本地缓存 + 短 TTL(Caffeine 缓存黑名单 1 分钟,即使 Redis 挂了最多 1 分钟延迟)。方案二:降级为只验证 Access Token 的签名和过期时间(退化为单令牌模式),虽然不能主动失效,但 Access Token 有效期短,影响有限。
面试追问 4:不引入 Refresh Token,用短期 Access Token + 每次重新登录行不行?
可以,但用户体验极差。想象一下:你打开银行 App,查个余额 → 输入密码,转个账 → 又输入密码。双令牌架构本质上就是"延长用户会话"同时"维持短 Token 的安全性"------这是权衡后的最优解。没有 Refresh Token 的方案,在安全性相等的情况下体验输了一大截。
双令牌 JWT 是目前企业级认证架构中,在安全性、体验、分布式友好三个维度上权衡得最均衡的方案。
读完这篇你应该能:画出从 Cookie 到双令牌 JWT 的完整演进路线图、解释清楚每一代方案解决了什么问题又引入了什么问题、独立搭建一套生产可用的 Spring Boot 双令牌认证模块、在面试时讲清楚 Access Token 和 Refresh Token 的设计动机而不只是背概念。
认证架构选什么方案,取决于你愿意为安全牺牲多少体验,又愿意为体验承担多少风险。