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

devtools/2025/3/17 16:48:39/

JWT_0">JWT概览

JWT_1">JWT概念

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

JWT_4">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(用户唯一标识)、jtiJWT唯一标识)等。
  • 公共的声明:可以定义任何名称,但应避免与注册的声明名称冲突。
  • 私有的声明:是提供者和消费者之间共同定义的声明。

负载同样会被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。

最佳实践

总体流程

JWT_97">JWT工具类

// JWT工具类
import io.jsonwebtoken.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;@Component
public 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;@Service
public 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;}@Overrideprotected 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.*;@RestController
public 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);// 替换旧refreshTokenredisService.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)传输。

http://www.ppmy.cn/devtools/167872.html

相关文章

数学建模 第一节

目录​​​​​​ 前言 一 优化模型的类型 二 线性规划1 线性规划2 三 0-1规划 总结 前言 数学建模主要是将问题转化为模型&#xff0c;然后再以编程的形式输出出来 算法都知道&#xff0c;数学建模也需要用到算法&#xff0c;但是不是主要以编程形式展示&#xff0c;而是…

【后端】【django drf】Django DRF API 编写规范(程序设计规则)

Django DRF API 编写规范&#xff08;程序设计规则&#xff09; 为了确保 Django DRF 代码的可维护性、可扩展性和高质量&#xff0c;API 设计不仅要符合 RESTful 规范&#xff0c;还需要遵循一定的程序设计规则。以下是一些关键的编写规范&#xff0c;以保证代码的清晰性、可…

DeepSeek R1 与 ktransformers:结合苹果 M4 Mac 的 LLM 推理深度分析

引言 大型语言模型&#xff08;LLM&#xff09;的快速发展为人工智能领域带来了革命性变化。DeepSeek R1 和 ktransformers 代表了软件层面的最新突破&#xff0c;而苹果在 2025 年 3 月 12 日发布的 M4 Mac 系列则提供了硬件支持。本文将深入分析这些技术的交汇点&#xff0c…

Git版本控制系统详解

文章目录 一、Git简介二、Git的基本原理三、Git的安装与配置安装配置 四、Git常用指令详解1. git init2. git clone3. git add4. git commit5. git status6. git diff7. git log8. git branch9. git checkout10. git merge11. git pull12. git push 五、Git的进阶用法1. 远程仓…

animes 和 css对比

Anime.js 并不是纯粹的 CSS 动画库&#xff0c;而是一个基于 JavaScript 的动画库。它可以通过操作 DOM 元素的属性&#xff08;如 style、transform、opacity 等&#xff09;来实现动画效果。虽然 Anime.js 的某些功能与 CSS 动画类似&#xff0c;但它的工作原理和功能范围与 …

HTTPS 证书相关

通常涉及到政府机构或官方组织的网站&#xff0c;这类网站对安全性和可信度要求较高&#xff0c;因此在选择 HTTPS 证书时需要特别注意。以下是适合的证书类型&#xff1a; 1. OV&#xff08;Organization Validation&#xff09;证书 适用场景&#xff1a;适用于需要验证组织…

设备管理VTY(Telnet、SSH)

实验目的&#xff1a;物理机远程VTY通过telnet协议登录AR1,ssh协议登录AR2和sw 注意配置Cloud1&#xff1a; 注意&#xff01;&#xff01;博主的物理机VMnet8--IP&#xff1a;192.168.160.1&#xff0c;所以AR1路由0/0/0端口才添加IP&#xff1a;192.168.160.3&#xff0c;每个…

Spring Boot + Vue 基于RSA+AES的混合加密

目录 一、后端实现 二、前端实现&#xff08;Vue2&#xff09; 三、补充 1.增强安全措施 四、最后说明 步骤大致如下&#xff1a; 后端生成RSA密钥对&#xff0c;提供公钥接口。前端请求公钥&#xff0c;生成随机AES密钥和IV。用RSA公钥加密AES密钥&#xff0c;用AES密钥加密…