本文主要介绍JWT的概念与使用注意事项。
概念
全称JSON Web Token,格式:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c它由三部分组成,用点(.)隔开:
Header(头部):说明用了什么算法(比如 HMAC SHA256)。
Payload(载荷):存放实际数据,比如用户ID、过期时间等。
Signature(签名):防止数据被篡改,用密钥对前两部分加密生成。
常用算法:HMAC。
缺点与解决方案
问题1:无法主动失效
JWT 是无状态的,服务器不保存已签发的 token。所以:
✅ 服务器不知道这个 token 是发给谁的
❌ 服务器也无法让某个 token "失效"
具体场景
// 1. 用户登录成功,获得 token
String token = jwtUtil.createJwt(secretKey, 3600000, claims);
// 2. 用户点击"退出登录" → 前端删除 token
// 但是!!!如果别人之前偷了这个 token,依然能用
// 3. 管理员封禁用户 → 数据库标记 user.status = 0
// 但是!!!用户的 token 依然有效,直到过期解决方案详解
方案A:黑名单机制(最常用)
原理:把要失效的 token 存到一个地方(通常是 Redis),每次请求检查是否在黑名单里。
代码实现:
@Component
public class JwtService {
@Autowired
private StringRedisTemplate redisTemplate;
// 生成 token
public String generateToken(Long userId) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("jti", UUID.randomUUID().toString()); // 唯一ID
return Jwts.builder()
.signWith(key)
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000)) // 15分钟
.compact();
}
// 登出:把 token 加入黑名单
public void logout(String token) {
// 解析 token 获取过期时间
Claims claims = parseToken(token);
if (claims != null) {
long expireTime = claims.getExpiration().getTime() - System.currentTimeMillis();
// 存入黑名单,有效期等于 token 剩余时间
redisTemplate.opsForValue().set(
"blacklist:" + claims.getId(),
"1",
expireTime,
TimeUnit.MILLISECONDS
);
}
}
// 验证 token
public boolean validateToken(String token) {
try {
Claims claims = parseToken(token);
if (claims == null) return false;
// 检查是否在黑名单
Boolean isBlacklisted = redisTemplate.hasKey("blacklist:" + claims.getId());
return !Boolean.TRUE.equals(isBlacklisted);
} catch (Exception e) {
return false;
}
}
}优缺点:
✅ 能主动失效
✅ 实现简单
❌ 失去无状态优势
❌ 每次请求都要查 Redis
方案B:短 token + Refresh Token(行业标准)
原理:两个 token 配合使用
Access Token:短期有效(15分钟),用于正常请求
Refresh Token:长期有效(7天),用于换取新的 Access Token
Refresh Token 可以是有状态的(存 Redis),也可以是无状态的(但需要额外机制)
完整实现:
@Component
public class JwtTokenService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final long ACCESS_TOKEN_EXPIRE = 15 * 60 * 1000; // 15分钟
private static final long REFRESH_TOKEN_EXPIRE = 7 * 24 * 60 * 60 * 1000; // 7天
// 登录:返回两个 token
public TokenPair login(String username, String password) {
// 1. 验证用户名密码...
Long userId = 123L;
// 2. 生成 access token
String accessToken = createAccessToken(userId);
// 3. 生成 refresh token(存 Redis)
String refreshToken = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"refresh:" + refreshToken,
userId.toString(),
REFRESH_TOKEN_EXPIRE,
TimeUnit.MILLISECONDS
);
return new TokenPair(accessToken, refreshToken);
}
// 刷新 token
public String refreshAccessToken(String refreshToken) {
// 1. 验证 refresh token
String userId = redisTemplate.opsForValue().get("refresh:" + refreshToken);
if (userId == null) {
throw new RuntimeException("refresh token 无效或已过期");
}
// 2. 生成新的 access token
return createAccessToken(Long.parseLong(userId));
}
// 登出:同时失效两个 token
public void logout(String accessToken, String refreshToken) {
// 1. access token 加入黑名单
Claims claims = parseToken(accessToken);
if (claims != null) {
redisTemplate.opsForValue().set(
"blacklist:" + claims.getId(),
"1",
ACCESS_TOKEN_EXPIRE,
TimeUnit.MILLISECONDS
);
}
// 2. 删除 refresh token
redisTemplate.delete("refresh:" + refreshToken);
}
private String createAccessToken(Long userId) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("jti", UUID.randomUUID().toString());
return Jwts.builder()
.signWith(key)
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE))
.compact();
}
}
@Data
class TokenPair {
private String accessToken;
private String refreshToken;
// 构造方法...
}工作流程:
1. 登录成功
└→ 返回 {accessToken: "15分钟有效", refreshToken: "7天有效"}
2. 正常请求
└→ 用 accessToken 调用接口
3. accessToken 过期
└→ 前端用 refreshToken 调用 /refresh 接口
└→ 后端验证 refreshToken,返回新的 accessToken
4. 用户登出
└→ accessToken 加入黑名单
└→ 删除 refreshToken优缺点:
✅ accessToken 短期有效,泄露影响小
✅ 用户体验好(自动续期)
✅ 可以主动登出
❌ 需要维护 refreshToken 的状态
方案C:版本号机制
原理:每个用户一个版本号,存在 Redis 里,JWT 里也带这个版本号,不一致就拒绝。
代码实现:
@Component
public class VersionedJwtService {
@Autowired
private StringRedisTemplate redisTemplate;
// 生成 token
public String generateToken(Long userId) {
// 获取用户当前版本号(没有则初始化为1)
Long version = redisTemplate.opsForValue().increment("version:" + userId, 0);
if (version == 0) {
version = 1L;
redisTemplate.opsForValue().set("version:" + userId, "1");
}
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("version", version);
return Jwts.builder()
.signWith(key)
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000))
.compact();
}
// 验证 token
public boolean validateToken(String token) {
Claims claims = parseToken(token);
if (claims == null) return false;
Long userId = claims.get("userId", Long.class);
Long tokenVersion = claims.get("version", Long.class);
// 获取当前版本号
String currentVersion = redisTemplate.opsForValue().get("version:" + userId);
return tokenVersion.equals(Long.parseLong(currentVersion));
}
// 修改密码/登出:版本号+1,所有旧 token 失效
public void invalidateAllTokens(Long userId) {
redisTemplate.opsForValue().increment("version:" + userId);
}
}优缺点:
✅ 一次操作让所有 token 失效(比如改密码后)
✅ 实现简单
❌ 必须查 Redis
❌ 不能针对单个 token 失效
问题2:Payload 信息泄露
JWT 的 Payload 只是 Base64 编码,不是加密!任何人都能解码看到内容。
// 假设你的 token 是:
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicGhvbmUiOiIxMzgwMDEzODAwMCJ9.signature"
// 在 https://jwt.io 上粘贴,直接就能看到:
{
"userId": 123,
"phone": "13800138000" // 手机号泄露了!
}解决方案详解
方案A:只放非敏感信息
// ❌ 错误:放敏感信息
claims.put("phone", "13800138000");
claims.put("idCard", "123456199001011234");
// ✅ 正确:只放标识性信息
claims.put("userId", 123);
claims.put("role", "ADMIN");方案B:敏感信息加密存储
@Component
public class SecureJwtService {
@Autowired
private RedisTemplate redisTemplate;
public String generateToken(Long userId) {
// token 里只放 userId
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
// 用户信息存 Redis(设置和 token 一样的过期时间)
UserInfo userInfo = getUserInfoFromDB(userId);
redisTemplate.opsForValue().set(
"user:" + userId,
JSON.toJSONString(userInfo),
30, TimeUnit.MINUTES
);
return Jwts.builder()
.signWith(key)
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000))
.compact();
}
// 需要用户信息时,从 Redis 获取
public UserInfo getUserInfo(String token) {
Claims claims = parseToken(token);
Long userId = claims.get("userId", Long.class);
String userJson = redisTemplate.opsForValue().get("user:" + userId);
return JSON.parseObject(userJson, UserInfo.class);
}
}方案C:使用 JWE(JSON Web Encryption)
如果非要放敏感信息,就用 JWE 加密整个 JWT:
// jjwt 支持 JWE(需要额外依赖)
String encryptedJwt = Jwts.builder()
.claims(claims)
.encryptWith(key, Jwts.KEY.A256GCM) // 加密
.compact();优缺点对比:
问题3:体积大
Session:通常几十字节
JWT:几百字节甚至几 KB
// 如果你放了太多信息
Map<String, Object> claims = new HashMap<>();
claims.put("userId", 123);
claims.put("username", "zhangsan");
claims.put("email", "zhangsan@example.com");
claims.put("roles", Arrays.asList("ADMIN", "USER", "MANAGER"));
claims.put("permissions", getAllPermissions()); // 几十个权限
claims.put("department", "技术部");
claims.put("avatar", "http://example.com/avatar.jpg");
// ... 越加越多
// 最终 token 可能 2-3 KB
// 每个请求都带 3KB 的数据!解决方案
方案A:精简 payload
// ❌ 不要放
- 完整的用户信息
- 权限列表(如果有几十个)
- 长的描述性字段
// ✅ 只放
- userId(必须)
- 角色/权限的缩写(如果有)
- 必要的业务标识方案B:压缩
// 使用短字段名
Map<String, Object> claims = new HashMap<>();
claims.put("uid", 123); // 代替 userId
claims.put("role", "A"); // 代替 ADMIN
claims.put("perm", "R,W,D"); // 用缩写
// 甚至可以用自定义序列化方案C:参考令牌模式
// token 里只放一个随机 ID
String tokenId = UUID.randomUUID().toString();
Map<String, Object> claims = new HashMap<>();
claims.put("tid", tokenId); // token ID
// 真正的用户信息存 Redis
redisTemplate.opsForValue().set(
"token:" + tokenId,
JSON.toJSONString(userInfo),
30, TimeUnit.MINUTES
);问题4:无状态的双刃剑
无状态带来扩展性的同时,也失去了控制力。需要控制但 JWT 本身做不到的事。
// 1. 限制单用户多地登录
// 2. 统计在线用户
// 3. 强制用户下线
// 4. 踢掉之前登录的设备解决方案
方案:引入会话管理
@Component
public class SessionManager {
@Autowired
private StringRedisTemplate redisTemplate;
// 登录成功时
public void loginSuccess(Long userId, String deviceId, String token) {
String key = "sessions:user:" + userId;
// 获取用户已有的 session
Map<Object, Object> sessions = redisTemplate.opsForHash().entries(key);
// 踢掉最早的设备(如果超过限制)
if (sessions.size() >= 3) { // 最多3个设备同时在线
String oldestToken = findOldestSession(sessions);
redisTemplate.opsForHash().delete(key, oldestToken);
redisTemplate.delete("session:" + oldestToken);
}
// 记录新 session
Map<String, String> sessionInfo = new HashMap<>();
sessionInfo.put("deviceId", deviceId);
sessionInfo.put("loginTime", String.valueOf(System.currentTimeMillis()));
redisTemplate.opsForHash().put(key, token, JSON.toJSONString(sessionInfo));
redisTemplate.opsForValue().set("session:" + token, userId.toString(), 30, TimeUnit.MINUTES);
}
// 踢掉某个设备
public void kickoutDevice(Long userId, String token) {
String key = "sessions:user:" + userId;
redisTemplate.opsForHash().delete(key, token);
redisTemplate.delete("session:" + token);
}
// 获取在线用户列表
public List<Long> getOnlineUsers() {
// 用 Redis 的 keys 或 scan 命令
// 实际生产环境要用专门的在线用户统计服务
}
}问题5:过期时间难把控
时间太短:用户体验差
时间太长:安全风险大
静态过期:所有 token 一样,不够灵活
解决方案
方案A:动态过期时间
public String generateToken(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
// 根据用户角色设置不同过期时间
long ttl;
if (user.isVIP()) {
ttl = 7 * 24 * 60 * 60 * 1000; // VIP 7天
} else if (user.isRememberMe()) {
ttl = 30 * 24 * 60 * 60 * 1000; // 记住我 30天
} else {
ttl = 30 * 60 * 1000; // 普通用户 30分钟
}
return Jwts.builder()
.signWith(key)
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + ttl))
.compact();
}方案B:滑动过期
// 每次访问时,如果 token 剩余时间少于某个阈值,就发新 token
public String checkAndRefreshToken(String oldToken) {
Claims claims = parseToken(oldToken);
if (claims == null) return null;
Date expiration = claims.getExpiration();
long remaining = expiration.getTime() - System.currentTimeMillis();
// 剩余时间少于 5 分钟,自动续期
if (remaining < 5 * 60 * 1000) {
return generateToken(claims.get("userId", Long.class));
}
return oldToken;
}各种方案对比表
建议组合
中小型项目:
Access Token: 30分钟
Refresh Token: 7天(存 Redis)
黑名单处理登出
Payload: 只放 userId、角色
用户信息存 Redis/数据库
大型分布式:
Access Token: 15分钟
Refresh Token: 存 Redis 或 数据库
版本号机制处理改密码
JWT 网关统一验证
使用配置中心管理密钥
总的来说,JWT 不是银弹,但结合 Redis 和合理的设计,完全可以满足生产需求。