hexon
发布于 2026-02-24 / 1 阅读
0

01-JWT详解

本文主要介绍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();

优缺点对比

方案

安全性

性能

实现复杂度

只放标识

⭐⭐⭐

⭐⭐⭐

⭐⭐⭐

Redis存储

⭐⭐⭐

⭐⭐

⭐⭐

JWE加密

⭐⭐⭐

问题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;
}

各种方案对比表

问题

解决方案

优点

缺点

适用场景

无法主动失效

黑名单

实现简单

需要查 Redis

小型系统

Refresh Token

安全、体验好

复杂

中大型系统

版本号

批量失效

不能单点失效

改密码场景

信息泄露

只放标识

简单安全

需要查存储

大部分场景

Redis存储

灵活

有状态

需要用户信息

JWE加密

端到端安全

性能差

极高安全要求

体积大

精简字段

简单

信息有限

所有场景

压缩

减小体积

处理开销

网络差场景

参考令牌

最小体积

有状态

移动端

无状态控制

会话管理

功能完善

失去无状态

需要精细控制

建议组合

中小型项目

  • Access Token: 30分钟

  • Refresh Token: 7天(存 Redis)

  • 黑名单处理登出

  • Payload: 只放 userId、角色

  • 用户信息存 Redis/数据库

大型分布式

  • Access Token: 15分钟

  • Refresh Token: 存 Redis 或 数据库

  • 版本号机制处理改密码

  • JWT 网关统一验证

  • 使用配置中心管理密钥

总的来说,JWT 不是银弹,但结合 Redis 和合理的设计,完全可以满足生产需求