> 技术文档 > 【SpringBoot】最佳实践——JWT结合Redis实现双Token无感刷新

【SpringBoot】最佳实践——JWT结合Redis实现双Token无感刷新


JWT概览

JWT概念

JWT是全称是JSON WEB TOKEN,是一个开放标准,用于将各方数据信息作为JSON格式进行对象传递,可以对数据进行可选的数字加密,可使用RSAECDSA进行公钥/私钥签名。JWT最常见的使用场景就是缓存当前用户登录信息,当用户登录成功之后,拿到JWT,之后用户的每一个请求在请求头携带上Authorization字段来辨别区分请求的用户信息。且不需要额外的资源开销。

JWT组成部分

JWT通常由一个头部(Header)、一个负载(Payload)和一个签名(Signature)三部分组成,这三部分之间用点(.)分隔。所以,一个完整的JWT看起来像这样:

xxxxx.yyyyy.zzzzz

下面我们来详细解析每一部分:

头部(header)

头部用于描述令牌的元数据,通常包含令牌的类型(即JWT)和所使用的签名算法(如HMAC SHA256)。

  • typ:表示令牌的类型,JWT令牌统一写为\"JWT\"。
  • alg:表示签名使用的算法,例如HMAC SHA256或RSA。

头部信息会被进行Base64编码,形成JWT的第一部分。

{ \"typ\": \"JWT\", \"alg\": \"HS256\" }

负载(payload)

负载包含了JWT的声明即传递的数据,这些数据通常包括用户信息和其他相关数据。声明有三种类型:注册的声明、公共的声明和私有的声明。

  • 注册的声明:这是一组预定义的声明,它们不是强制的,但是推荐使用,以提供一组有用的、可互操作的声明。如:iat(签发时间)、exp(过期时间)、aud(接收方)、sub(用户唯一标识)、jti(JWT唯一标识)等。
  • 公共的声明:可以定义任何名称,但应避免与注册的声明名称冲突。
  • 私有的声明:是提供者和消费者之间共同定义的声明。

负载同样会被Base64编码,形成JWT的第二部分。

{ \"sub\": \"1234567890\", \"name\": \"John Doe\", \"jti\": \"unique-jwt-id\", \"admin\": true }

签名(signature)

签名将头部和负载用指定的算法进行签名,验证JWT的真实性和完整性。当接收者收到JWT时,他们可以使用相同的算法和密钥(对于HMAC算法)或使用公钥(对于RSA或ECDSA算法)验证签名。如果两个签名匹配,那么JWT就是有效的。

签名的过程如下:

  • 先将Base64编码后的头部和负载数据用点号(.)连接起来。
  • 使用指定的签名算法(例如,HMAC SHA256、RSA、ECDSA)和密钥对连接后的字符串进行签名。
  • 将生成的签名部分进行Base64Url编码,形成JWT的第三部分。

签名部分也是经过Base64Url编码的,形成JWT的第三部分。

HMACSHA256( base64UrlEncode(header) + \".\" + base64UrlEncode(payload), secret)

注意:虽然Base64Url编码不是加密方式,但它可以确保JWT的字符串格式是紧凑的,并且容易在URL、POST参数或HTTP头部中传输。

技术方案设计

单点登录(SSO)

  • 单点登录(Single Sign-On, SSO) 是一种身份认证机制,允许用户通过一次登录即可访问多个相互信任的应用系统,而无需重复输入认证信息。

双Token机制

  • AccessToken
    • 短期有效(如30分钟),用于接口访问。
    • 客户端每次请求API时携带。
    • 不持久化存储,仅通过签名验证合法性。
  • RefreshToken
    • 用于获取新的Access Token,有效期长(如3天)。
    • 仅在刷新令牌时传输,不直接访问业务API。
    • 必须持久化存储(如Redis),服务端可主动使其失效。
  • 签名算法:使用RSA非对称加密算法,减少内存占用,防止篡改,并方便后续拓展子系统。

无感刷新Token

  • 客户端将由于AccesssToken过期失败的请求存储起来,携带RefreshToken成功刷新Token后,将存储的失败请求重新发起,以此达到用户无感的体验。
  • 服务端根据RefreshToken解析出userId和deviceId后,去Redis中查询存储的RefreshToken并进行比对,成功后生成新的AT和RT并返回

多端会话管理

  • 同一账号在不同设备登录时,为每个设备生成独立的RefreshToken。
  • Redis中以 userId:deviceId为键存储RefreshToken,过期时间设置为RefreshToken的过期时间。

废弃令牌移除

  • Redis中以 blacklist:token 为键存储AccessToken黑名单,键值对的过期时间设置为AccessToken的剩余有效期。
  • 直接删除Redis中的RefreshToken。

最佳实践

总体流程

【SpringBoot】最佳实践——JWT结合Redis实现双Token无感刷新

JWT工具类

// JWT工具类import io.jsonwebtoken.*;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Component;import java.util.Date;@Componentpublic class JwtUtil { @Value(\"${jwt.secret}\") private String secret; @Value(\"${jwt.access.expiration}\") private long accessExpiration; @Value(\"${jwt.refresh.expiration}\") private long refreshExpiration; public String generateAccessToken(String username) { return Jwts.builder() .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + accessExpiration)) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } public String generateRefreshToken(String username) { return Jwts.builder() .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + refreshExpiration)) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(secret).parseClaimsJws(token); return true; } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) { return false; } } public String getUsernameFromToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody().getSubject(); }}

Redis服务类

// Redis服务类import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.stereotype.Service;import java.util.concurrent.TimeUnit;@Servicepublic class RedisService { private final StringRedisTemplate redisTemplate; public RedisService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public void saveRefreshToken(String refreshToken, String username) { redisTemplate.opsForValue().set(\"refresh_token:\" + refreshToken, username, 7, TimeUnit.DAYS); } public boolean isRefreshTokenValid(String refreshToken) { return redisTemplate.hasKey(\"refresh_token:\" + refreshToken); } public void deleteRefreshToken(String refreshToken) { redisTemplate.delete(\"refresh_token:\" + refreshToken); } public void addToBlacklist(String accessToken, long expirationMs) { redisTemplate.opsForValue().set(\"blacklist:\" + accessToken, \"invalid\", expirationMs, TimeUnit.MILLISECONDS); } public boolean isInBlacklist(String accessToken) { return Boolean.TRUE.equals(redisTemplate.hasKey(\"blacklist:\" + accessToken)); }}

Filter过滤器

// JWT过滤器import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.web.filter.OncePerRequestFilter;import javax.servlet.FilterChain;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;public class JwtFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final RedisService redisService; public JwtFilter(JwtUtil jwtUtil, RedisService redisService) { this.jwtUtil = jwtUtil; this.redisService = redisService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (token == null) { filterChain.doFilter(request, response); return; } if (redisService.isInBlacklist(token)) { sendError(response, \"Token invalid\"); return; } if (jwtUtil.validateToken(token)) { String username = jwtUtil.getUsernameFromToken(token); UsernamePasswordAuthenticationToken authentication =  new UsernamePasswordAuthenticationToken(username, null, null); SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(request, response); } else { sendError(response, \"Token expired or invalid\"); } } private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader(\"Authorization\"); if (bearerToken != null && bearerToken.startsWith(\"Bearer \")) { return bearerToken.substring(7); } return null; } private void sendError(HttpServletResponse response, String message) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write(message); response.getWriter().flush(); }}

Controller类

// 控制器类import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;@RestControllerpublic class AuthController { private final JwtUtil jwtUtil; private final RedisService redisService; public AuthController(JwtUtil jwtUtil, RedisService redisService) { this.jwtUtil = jwtUtil; this.redisService = redisService; } @PostMapping(\"/login\") public ResponseEntity<?> login(@RequestBody LoginRequest request) { // 这里应添加用户认证逻辑(如数据库验证) String username = request.getUsername(); String accessToken = jwtUtil.generateAccessToken(username); String refreshToken = jwtUtil.generateRefreshToken(username); redisService.saveRefreshToken(refreshToken, username); return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken)); } @PostMapping(\"/refresh\") public ResponseEntity<?> refreshToken(@RequestBody RefreshRequest request) { String refreshToken = request.getRefreshToken(); if (!redisService.isRefreshTokenValid(refreshToken)) { return ResponseEntity.status(401).body(\"Invalid refresh token\"); } String username = jwtUtil.getUsernameFromToken(refreshToken); String newAccessToken = jwtUtil.generateAccessToken(username); String newRefreshToken = jwtUtil.generateRefreshToken(username); // 替换旧refreshToken redisService.deleteRefreshToken(refreshToken); redisService.saveRefreshToken(newRefreshToken, username); // 将旧accessToken加入黑名单(可选) // long expiration = jwtUtil.getExpirationFromToken(refreshToken).getTime() - System.currentTimeMillis(); // redisService.addToBlacklist(refreshToken, expiration); return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken)); } // DTO类 private static class LoginRequest { private String username; private String password; // getters/setters } private static class RefreshRequest { private String refreshToken; // getters/setters } private static class TokenResponse { private final String accessToken; private final String refreshToken; // constructor/getters }}

问题解析

相比单Token的优势

  • 高安全性:用户请求仅携带过期时间较短的AccessToken,即使令牌泄露,风险时间窗口也较小;用户仅在请求刷新Token时携带RefreshToken
  • 长会话:RefreshToken一般设置较长的过期时间,只要RT不过期用户就无需重复登录

引入Redis的作用

  • 方便状态管理:如果不存在Redis,用户登出后只能等待Token过期才能被动失效,增加Token暴露风险;通过在Redis中引入黑名单blacklist,可以使得Token主动失效
  • 多端会话管理:通过以 userId:deviceId为键存储不同设备的Token,实现同用户多端登录。通过删除对应设备的键并加上黑名单,可以主动剔出对应设备
  • 分布式一致性:若使用本地内存存储 RT,在分布式多节点架构中,各节点无法共享 RT 状态,导致用户在一个节点退出后,其他节点仍认为 RT 有效。Redis作为集中式存储,确保所有服务节点访问同一份 RT 数据,状态一致。

保证Token安全性

  • 存储安全性:AT存于内存或 SessionStorage(页面关闭失效),而RT通过 HttpOnly; Secure; SameSite=Strict Cookie 存储(XSS攻击无效)。
  • 传输安全性:开启HTTPS,防止中间人攻击(篡改、伪造和窃听);AT通过 Authorization: Bearer {token} 请求头传递,避免 URL 参数(防日志泄露),而RT通过 Cookie(标记 HttpOnly; Secure)传输。

武汉旅行社服务