JWT登录
JWT的ToKen生成
- JWT令牌分为三部分,Hwader记录算法和Token类型,一般由SigWith()自动生成,PayLoad存储业务信息比如过期时间,用户名等,Signature使用密匙+算法对Header+PayLoad进行加密
- 在Util包下会有一个JwtUtils类专门用于Jwt的生成,缓存
- 生成 ToKen
//私有密匙,配置在配置文件中经过Base64编码之后的一串字符串
@Value("${jwt.secret-key}")
private String secretKeyBase64;
/**
* 解析 Base64 密钥,并返回 SecretKey
*/
private SecretKey getSigningKey() {
byte[] keyBytes = Base64.getDecoder().decode(secretKeyBase64);
return Keys.hmacShaKeyFor(keyBytes);
}
// 生成唯一的tokenId, 生成过期时间
String tokenId = generateTokenId();
long expireTime = System.currentTimeMillis() + EXPIRATION_TIME;
// 创建token内容, 这个clams就是要一个键值对的集合, 但是不能存入敏感信息,因为 PayLoad 只是经过 Base64 编码加密
Map<String, Object> claims = new HashMap<>();
claims.put("tokenId", tokenId); // 添加tokenId用于Redis缓存
claims.put("role", user.getRole().name());
claims.put("userId", user.getId().toString()); // 添加用户ID到JWT
//最后生成token, 在sigWith中需要传入密匙经过解析之后生成的SecretKey对象
String token = Jwts.builder()
.setClaims(claims)
.setSubject(username)
.setExpiration(new Date(expireTime))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
- ToKen在Redis中的缓存, 通过 token_id 把用户的信息比如 id, name 存储到一个map中,最后用 String 的形式缓存到Redis中,同时要把 token_id 绑定到用户的 token 集合(数据结构主要使用的是 Set 集合)中,可以通过 Redis Key 的前缀来区分进行不同的缓存,这样进行双向绑定可以通过用户查到所有有效的 ToKen,在用户下线的时候批量删除,也可以限制用户多台设备的登录
// Redis key前缀
private static final String TOKEN_PREFIX = "jwt:valid:";
private static final String USER_TOKENS_PREFIX = "jwt:user:";
private static final String REFRESH_PREFIX = "jwt:refresh:";
private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
try {
String key = TOKEN_PREFIX + tokenId;
Map<String, Object> tokenInfo = new HashMap<>();
tokenInfo.put("userId", userId);
tokenInfo.put("username", username);
tokenInfo.put("expireTime", expireTimeMs);
// 计算Redis过期时间(比JWT过期时间稍长一点)
long ttlSeconds = (expireTimeMs - System.currentTimeMillis()) / 1000 + 300; // 多5分钟缓冲
redisTemplate.opsForValue().set(key, tokenInfo, ttlSeconds, TimeUnit.SECONDS);
// 同时添加到用户token集合中
addTokenToUser(userId, tokenId, expireTimeMs);
logger.debug("Token cached: {} for user: {}", tokenId, username);
} catch (Exception e) {
logger.error("Failed to cache token: {}", tokenId, e);
}
-
Refresh RoKen 生成:普通的 ToKen 只是短暂的比如一个小时,Refresh Token 是长期的比如7天,在登录的时候会同时生成两个 token,如果 AT(Access Token)过期,前端用 RT 调用 /refresh-token 接口,后端校验再向前端返回新的 AT, 这样用户就不用每次都登录访问。 如果用户修改密码,就立刻清理 Redis 中所有的 RT,进行重新登录。所以在登录接口中给前端返回的data 中同时包含 token, Refresh ToKen 两个
-
JWT对用户信息(通过map键值对形式保存在了 claims 中)的提取,提取的方法分为忽略过期时间和不忽略,两种分场景选择,忽略过期时间是有可能还需要一些用户的信息,比如双 ToKen 中 AT 过期,但并不等于跳过权限; 在这个 JWT 中获取了用户名之后,再根据姓名去数据库中找到对应数据
/**
* 提取Claims,忽略过期异常
*/
private Claims extractClaimsIgnoreExpiration(String token) {
try {
return Jwts.parserBuilder() // 1. 创建JWT解析器构建器
.setSigningKey(getSigningKey()) // 2. 设置签名密钥(验证token的合法性)
.build() // 3. 构建最终的解析器实例
.parseClaimsJws(token) // 4. 解析JWT token,验证签名和有效性
.getBody(); // 5. 提取并返回token中的Claims(载荷)
} catch (ExpiredJwtException e) {
// 忽略过期异常,返回claims
return e.getClaims();
} catch (Exception e) {
logger.debug("Cannot extract claims from token: {}", e.getMessage());
return null;
}
}
/**
* 提取Claims(正常验证)
*/
private Claims extractClaims(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
return null;
}
}
/**
* 从 JWT Token 中提取用户名,如果 JWT 校验失败就会返回 null, 成功就返回对应 claims, 其中 Subject() 设置的时候就是用户名
*/
public String extractUsernameFromToken(String token) {
try {
Claims claims = extractClaimsIgnoreExpiration(token);
return claims != null ? claims.getSubject() : null;
} catch (Exception e) {
logger.error("Error extracting username from token: {}", token, e);
return null;
}
}

浙公网安备 33010602011771号