Skip to content
第 65 / 250 章后端⏱ 10 分钟阅读

第 65 章:JWT 深入

学习目标

  • 深入理解 JWT 三段结构与签名原理
  • 掌握 Access Token + Refresh Token 双令牌机制
  • 学会 JWT 的安全加固(吊销、黑名单、最佳实践)

一、JWT 的本质

JWT = JSON + 签名。三段式结构:

eyJhbGciOiJIUzI1NiJ9        ← Header(算法)
.
eyJzdWIiOiIxMjM0NSIsInVzZXJuYW1lIjoiemhhbmdzYW4ifQ    ← Payload(数据)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c         ← Signature(签名)

二、三段详解

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

维度JWTSession
存储客户端(无状态)服务端(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(非对称)
双 TokenAccess(短期 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 权限模型

本站基于 VitePress 构建 · 由 Codebook 团队维护