第 65 章:JWT 深入
学习目标
- 深入理解 JWT 三段结构与签名原理
- 掌握 Access Token + Refresh Token 双令牌机制
- 学会 JWT 的安全加固(吊销、黑名单、最佳实践)
一、JWT 的本质
JWT = JSON + 签名。三段式结构:
eyJhbGciOiJIUzI1NiJ9 ← Header(算法)
.
eyJzdWIiOiIxMjM0NSIsInVzZXJuYW1lIjoiemhhbmdzYW4ifQ ← Payload(数据)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature(签名)二、三段详解
Header
json
{
"alg": "HS256", // 签名算法:HS256 / RS256 / ES256
"typ": "JWT"
}| 算法 | 密钥 | 性能 | 适用 |
|---|---|---|---|
| HS256 | 对称(一把密钥) | 快 | 单服务 |
| RS256 | 非对称(公钥+私钥) | 慢 | 多服务 |
| ES256 | 椭圆曲线 | 中 | 高安全要求 |
Payload
json
{
"sub": "1001", // 标准字段:用户 ID
"username": "zhangsan",
"roles": ["ROLE_ADMIN", "ROLE_USER"],
"tenantId": 100,
"iat": 1700000000, // 签发时间
"exp": 1700003600, // 过期时间(Unix 时间戳)
"iss": "taskflow.com", // 签发者
"aud": "taskflow-web" // 受众
}警告:Payload 是 Base64 编码,不是加密!任何人都能解码看到内容。 绝不能在 JWT 里存密码、身份证、手机号等敏感信息!
Signature
java
// 签名 = HMAC-SHA256(base64(header) + "." + base64(payload), secret)
// 验证时重新计算签名,对比是否一致
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) == signature签名保证了:拿不到 secret,改不了 Payload(改了签名就对不上)。
三、Java 实现
xml
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>java
@Component
@Slf4j
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-expire:7200}") // Access Token 2 小时
private Long accessExpire;
@Value("${jwt.refresh-expire:2592000}") // Refresh Token 30 天
private Long refreshExpire;
@Value("${jwt.issuer:taskflow}")
private String issuer;
private SecretKey key;
@PostConstruct
public void init() {
// ① 密钥至少 256 位(32 字节)
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalArgumentException("JWT 密钥至少 32 字节");
}
this.key = Keys.hmacShaKeyFor(bytes);
}
// ② 生成 Access Token(短期)
public String generateAccessToken(Long userId, String username,
List<String> roles, Long tenantId) {
Date now = new Date();
return Jwts.builder()
.issuer(issuer)
.subject(String.valueOf(userId))
.claim("username", username)
.claim("roles", roles)
.claim("tenantId", tenantId)
.claim("type", "access")
.issuedAt(now)
.expiration(new Date(now.getTime() + accessExpire * 1000))
.signWith(key, Jwts.SIG.HS256)
.compact();
}
// ③ 生成 Refresh Token(长期,仅用于刷新)
public String generateRefreshToken(Long userId) {
Date now = new Date();
return Jwts.builder()
.issuer(issuer)
.subject(String.valueOf(userId))
.claim("type", "refresh")
.issuedAt(now)
.expiration(new Date(now.getTime() + refreshExpire * 1000))
.id(UUID.randomUUID().toString()) // 唯一 ID,用于吊销
.signWith(key, Jwts.SIG.HS256)
.compact();
}
// ④ 解析 token
public Claims parse(String token) {
return Jwts.parser()
.verifyWith(key)
.requireIssuer(issuer)
.build()
.parseSignedClaims(token)
.getPayload();
}
// ⑤ 检查 token 类型
public boolean isAccessToken(String token) {
try {
Claims claims = parse(token);
return "access".equals(claims.get("type", String.class));
} catch (Exception e) {
return false;
}
}
}四、双 Token 机制(核心模式)
java
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
private final RefreshTokenService refreshTokenService;
private final UserMapper userMapper;
@PostMapping("/login")
public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto) {
// ① 认证
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
dto.getUsername(), dto.getPassword()));
// ② 生成双 token
User user = userMapper.selectByUsername(dto.getUsername());
List<String> roles = user.getRoles().stream()
.map(Role::getCode).toList();
String accessToken = jwtUtil.generateAccessToken(
user.getId(), user.getUsername(), roles, user.getTenantId());
String refreshToken = jwtUtil.generateRefreshToken(user.getId());
refreshTokenService.save(user.getId(), refreshToken); // 存 Refresh Token
return Result.ok(new LoginVO(accessToken, refreshToken));
}
@PostMapping("/refresh")
public Result<LoginVO> refresh(@RequestHeader("Refresh-Token") String refreshToken) {
// ① 验证 Refresh Token
Claims claims = jwtUtil.parse(refreshToken);
if (!"refresh".equals(claims.get("type", String.class))) {
throw new BusinessException(ErrorCode.PARAM_INVALID, "非法的 Refresh Token");
}
Long userId = Long.valueOf(claims.getSubject());
// ② 检查黑名单
if (refreshTokenService.isRevoked(refreshToken)) {
throw new BusinessException(ErrorCode.TOKEN_EXPIRED, "Refresh Token 已失效");
}
// ③ 验证 token 是否匹配(防 token 重放)
if (!refreshTokenService.matches(userId, refreshToken)) {
throw new BusinessException(ErrorCode.PARAM_INVALID, "Refresh Token 不匹配");
}
// ④ 重新生成
User user = userMapper.selectById(userId);
List<String> roles = user.getRoles().stream().map(Role::getCode).toList();
String newAccess = jwtUtil.generateAccessToken(
user.getId(), user.getUsername(), roles, user.getTenantId());
String newRefresh = jwtUtil.generateRefreshToken(userId);
refreshTokenService.save(userId, newRefresh);
// ⑤ 吊销旧 Refresh Token(轮换)
refreshTokenService.revoke(refreshToken);
return Result.ok(new LoginVO(newAccess, newRefresh));
}
@PostMapping("/logout")
public Result<Void> logout(@RequestHeader("Authorization") String accessToken,
@RequestHeader("Refresh-Token") String refreshToken,
Authentication auth) {
// 吊销当前 token
Long userId = ((CustomUserDetails) auth.getPrincipal()).getId();
refreshTokenService.revoke(refreshToken);
tokenBlacklistService.addToBlacklist(extractToken(accessToken));
return Result.ok();
}
}五、Refresh Token 存储与轮换
java
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RedisTemplate<String, Object> redis;
private static final String REFRESH_KEY_PREFIX = "refresh:";
private static final Duration EXPIRE = Duration.ofDays(30);
// ① 保存(一个用户只保留一个 Refresh Token)
public void save(Long userId, String token) {
// 删除旧的(强制单端登录)
redis.delete(REFRESH_KEY_PREFIX + userId);
redis.opsForValue().set(REFRESH_KEY_PREFIX + userId, token, EXPIRE);
}
public boolean matches(Long userId, String token) {
Object saved = redis.opsForValue().get(REFRESH_KEY_PREFIX + userId);
return saved != null && saved.equals(token);
}
public void revoke(String token) {
// 解析 jti 加入黑名单
// 简化版:直接删除
}
public boolean isRevoked(String token) {
return Boolean.TRUE.equals(redis.hasKey("revoked:" + token));
}
}六、Token 黑名单(强制下线)
java
@Service
@RequiredArgsConstructor
public class TokenBlacklistService {
private final RedisTemplate<String, Object> redis;
// ① 加入黑名单
public void addToBlacklist(String token, Claims claims) {
// 计算剩余有效期
long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
if (ttl > 0) {
// 黑名单只需要保留到 token 自然过期
redis.opsForValue().set(
"blacklist:" + token, "1", ttl, TimeUnit.MILLISECONDS);
}
}
// ② 检查
public boolean isBlacklisted(String token) {
return Boolean.TRUE.equals(redis.hasKey("blacklist:" + token));
}
}java
// 在 JwtAuthenticationFilter 里加判断
if (blacklistService.isBlacklisted(token)) {
throw new BusinessException(ErrorCode.TOKEN_INVALID, "Token 已失效");
}七、JWT 的典型攻击与防御
攻击 1:none 算法绕过
java
// ❌ 攻击者把 Header 改成 {"alg":"none"},不签名
// 一些老版本库会接受不签名的 token
// ✅ 防御:解析时显式指定算法
Jwts.parser()
.verifyWith(key) // 必须用密钥验证
.sigAlg(Jwts.SIG.HS256) // 显式指定算法(防止 alg 替换)
.build();攻击 2:密钥泄露
yaml
# ❌ 密钥硬编码
jwt:
secret: my-super-secret-key-123456
# ✅ 密钥从环境变量读取
jwt:
secret: ${JWT_SECRET} # 启动时从环境变量注入
# ✅ 定期轮换密钥(用 key id 区分)
jwt:
secret: ${JWT_SECRET_2026_08}攻击 3:Token 盗用
java
// HTTPS:必须!HTTP 下 token 明文传输,直接被截获
server:
port: 443
ssl:
enabled: true
// 设置 HttpOnly Cookie(防止 XSS 盗取)
Cookie cookie = new Cookie("token", accessToken);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath("/");
response.addCookie(cookie);
// 设置 SameSite 防止 CSRF
cookie.setAttribute("SameSite", "Strict");攻击 4:Token 重放
java
// 用 jti(JWT ID)+ 黑名单防重放
String jti = claims.getId();
if (redis.hasKey("replay:" + jti)) {
throw new BusinessException("Token 已被使用过");
}
redis.opsForValue().set("replay:" + jti, "1", expireTime, TimeUnit.SECONDS);八、JWT vs Session
| 维度 | JWT | Session |
|---|---|---|
| 存储 | 客户端(无状态) | 服务端(Redis) |
| 跨服务 | ✅ 天然支持 | ❌ 需要 Session 共享 |
| 撤销 | 困难(需黑名单) | ✅ 简单(删 Session) |
| Token 大小 | 大(几百字节) | 小(Session ID 几十字节) |
| 移动端 | ✅ 友好 | 需 Cookie |
| 适用 | 前后端分离、移动 App | 传统 Web、SSO 单点登录 |
选择建议:
- 前后端分离、移动 App → JWT
- 传统 Web、需要 SSO → Session
- 高安全要求(金融) → Session + 短时 JWT
九、最佳实践清单
- ✅ 用 HTTPS 传输
- ✅ Access Token 短(2 小时),Refresh Token 长(30 天)
- ✅ Refresh Token 轮换(每次刷新生成新的)
- ✅ 单端登录(一账号一 Refresh Token)
- ✅ 敏感操作二次校验(改密码、支付 → 重新输密码 / 验证码)
- ✅ 密钥至少 32 字节,定期轮换
- ✅ Payload 不存敏感信息
- ✅ 必要时强制下线(黑名单机制)
- ✅ 防 XSS / CSRF:HttpOnly Cookie、SameSite、CSP
十、本章小结
| 要点 | 关键 |
|---|---|
| 本质 | Header.Payload.Signature(Base64,不是加密) |
| 算法 | HS256(对称)/ RS256(非对称) |
| 双 Token | Access(短期 2h)+ Refresh(长期 30d) |
| 轮换 | 每次刷新都换新的 Refresh Token |
| 强制下线 | 黑名单 + Redis(TTL 到 token 过期为止) |
| 防御 | HTTPS、HttpOnly、密钥轮换、算法锁定 |
动手练习
练习 1:基础题
实现完整的登录接口:返回 Access Token + Refresh Token,并实现 /refresh 刷新接口。
练习 2:进阶题
实现 Token 强制下线:用户在 A 设备登录后,B 设备的 Token 自动失效(Refresh Token 轮换 + 黑名单)。
练习 3:思考题
你的系统支持微信公众号、微信小程序、Web 三端登录。如何设计一套统一的 JWT 方案?
下一章:第 66 章:RBAC 权限模型 →