JWT概览
JWT概念
JWT
是全称是JSON WEB TOKEN
,是一个开放标准,用于将各方数据信息作为JSON格式进行对象传递,可以对数据进行可选的数字加密,可使用RSA
或ECDSA
进行公钥/私钥签名。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。
最佳实践
总体流程
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;
}
@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.*;
@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);
// 替换旧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
)传输。