JWT 认证安全实践与常见漏洞

JWT 认证安全实践与常见漏洞

JWT 是 RESTful API 最常用的无状态认证方案,但算法混淆、签名缺失、弱密钥等问题会让 Token 认证形同虚设。

本文是 Java Web 认证授权安全系列 的第四篇。


4.1 算法混淆攻击

攻击原理:服务端同时接受 RS256(非对称)和 HS256(对称)算法时,攻击者可将算法改为 HS256,然后用公开的公钥作为 HMAC 密钥签名 Token。

// ❌ 危险:同时接受多种算法
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
        .jwsAlgorithms(algs -> {
            algs.add(JWSAlgorithm.RS256);
            algs.add(JWSAlgorithm.HS256);   // ← 危险!
        })
        .build();
}
// ✅ 正确:只接受单一算法
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
        .jwsAlgorithm(JWSAlgorithm.RS256)
        .build();
}

4.2 alg: none 攻击

某些库在配置不当时接受未签名 Token:

{"alg": "none"}
{"sub": "admin", "role": "ADMIN"}
// 无签名部分

Java 常见错误

// ❌ parseClaimsJwt 不验证签名!
Claims claims = Jwts.parser().parseClaimsJwt(token).getBody();

// ✅ parseClaimsJws 强制验证签名
Claims claims = Jwts.parser()
    .setSigningKey(secretKey)
    .parseClaimsJws(token)  // ← 带 's'
    .getBody();
// ❌ catch 范围过大,掩盖 UnsupportedJwtException
public boolean validateToken(String token) {
    try {
        Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
        return true;
    } catch (Exception e) {  // ← 太宽泛
        return false;
    }
}

修复:分别处理 UnsupportedJwtExceptionSignatureExceptionExpiredJwtException


4.3 弱密钥与密钥管理

// ❌ 弱密钥 / 硬编码
String secret = "mySecretKey";  // 太短
String secret = "abc123def456ghi789jkl012mno345pq";  // 硬编码

// ✅ 正确做法
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);  // 自动生成 256 位
// 或从 Vault/KMS 安全存储读取

CVE-2024-31033:JJWT 在处理 setSigningKey() 时某些字符被忽略。建议:使用 byte[] 传参,升级最新版。


4.4 Token 生命周期与吊销

String token = Jwts.builder()
    .setSubject(userId)
    .setIssuer("https://auth.example.com")
    .setAudience("api.example.com")
    .setIssuedAt(new Date())
    .setExpiration(new Date(System.currentTimeMillis() + 900_000))  // 15分钟
    .signWith(privateKey, SignatureAlgorithm.RS256)
    .compact();

验证

Jws<Claims> jws = Jwts.parserBuilder()
    .setSigningKey(publicKey)
    .requireIssuer("https://auth.example.com")
    .requireAudience("api.example.com")
    .build()
    .parseClaimsJws(token);

吊销策略:JWT 无状态无法撤销,需配合:

  • 短过期 + Refresh Token 轮换
  • Redis 黑名单存储已吊销 Token ID
  • 关键操作强制重新认证

4.5 JWT 安全检查清单

@Component
public class JwtTokenValidator {
    private final SecretKey secretKey;
    private final RedisTemplate<String, String> redis;

    public Claims validateAndParse(String token) {
        String tokenId = extractTokenId(token);
        if (Boolean.TRUE.equals(redis.hasKey("token:revoked:" + tokenId))) {
            throw new SecurityException("Token revoked");
        }

        return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .requireIssuer("auth.example.com")
            .requireAudience("api.example.com")
            .setAllowedClockSkewSeconds(30)
            .build()
            .parseClaimsJws(token)  // ← parseClaimsJws!
            .getBody();
    }
}

4.6 JWE 加密 Token(补充)

JWT 的 payload 是 Base64 编码,不是加密。任何人都能看到其中内容:

// 这个 payload 任何人拿到 Token 即可解码阅读
{"userId": 123, "role": "admin", "internalDeptId": 42}

JWE(JSON Web Encryption) 提供真正的加密:

// ✅ JWE 加密 Token — 内容不可见
public String generateEncryptedToken(User user) throws Exception {
    // 1. 构建 JWT Claims
    JWTClaimsSet claims = new JWTClaimsSet.Builder()
        .subject(user.getId().toString())
        .claim("role", user.getRole())
        .issueTime(new Date())
        .expirationTime(new Date(System.currentTimeMillis() + 900_000))
        .build();

    // 2. 创建 JWE 头部
    JWEHeader header = new JWEHeader(
        JWEAlgorithm.RSA_OAEP_256,   // 密钥加密算法
        EncryptionMethod.A256GCM     // 内容加密算法
    );

    // 3. 加密
    EncryptedJWT jwt = new EncryptedJWT(header, claims);
    RSAEncrypter encrypter = new RSAEncrypter(publicKey);
    jwt.encrypt(encrypter);

    return jwt.serialize();
}

// 解密
public Claims decryptAndValidate(String encryptedToken) throws Exception {
    EncryptedJWT jwt = EncryptedJWT.parse(encryptedToken);
    RSADecrypter decrypter = new RSADecrypter(privateKey);
    jwt.decrypt(decrypter);
    
    // 然后再做常规 JWT 校验
    return jwt.getJWTClaimsSet();
}

何时使用 JWE

  • Token 中必须携带敏感信息(身份证号、手机号等)
  • 合规要求(GDPR、等保)
  • Token 可能暴露在浏览器 localStorage 中

不推荐 JWE 的场景

  • 高性能要求(加密解密有开销)
  • 网关层需要读取 Token 内容做路由判断
  • Token 仅用于服务端会话关联(用参考 Token 更合适)

4.7 Refresh Token 轮换策略(补充)

@Service
public class TokenRotationService {

    @Autowired
    private RedisTemplate<String, String> redis;

    // Access Token: 15 分钟
    private static final Duration ACCESS_EXPIRY = Duration.ofMinutes(15);
    // Refresh Token: 7 天
    private static final Duration REFRESH_EXPIRY = Duration.ofDays(7);
    // Refresh Token 家族前缀,用于检测重用
    private static final String TOKEN_FAMILY = "token:family:";

    public TokenPair issueTokens(String userId) {
        String familyId = UUID.randomUUID().toString();
        String refreshId = UUID.randomUUID().toString();

        // 存储 refresh token 家族
        redis.opsForValue().set(TOKEN_FAMILY + familyId, refreshId, REFRESH_EXPIRY);

        String accessToken = generateAccessToken(userId, familyId);
        String refreshToken = generateRefreshToken(userId, familyId, refreshId);

        return new TokenPair(accessToken, refreshToken);
    }

    public TokenPair rotateTokens(String oldRefreshToken) {
        Claims claims = validateRefreshToken(oldRefreshToken);
        String familyId = claims.get("familyId", String.class);
        String oldRefreshId = claims.get("refreshId", String.class);

        // 检查 refresh token 是否已被使用(检测重用 → 说明被盗)
        String storedRefreshId = redis.opsForValue().get(TOKEN_FAMILY + familyId);
        if (storedRefreshId == null || !storedRefreshId.equals(oldRefreshId)) {
            // Refresh Token 重用!可能是被盗。
            // 立即吊销整个家族
            redis.delete(TOKEN_FAMILY + familyId);
            redis.opsForValue().set("token:family:revoked:" + familyId, "1", Duration.ofDays(7));
            
            // 强制用户重新登录,并发出告警
            log.warn("Refresh Token 重用检测:familyId={}", familyId);
            throw new SecurityException("Token 已被使用,请重新登录");
        }

        // 正常轮换:换发新的 refresh token
        String newRefreshId = UUID.randomUUID().toString();
        redis.opsForValue().set(TOKEN_FAMILY + familyId, newRefreshId, REFRESH_EXPIRY);

        String newAccessToken = generateAccessToken(claims.getSubject(), familyId);
        String newRefreshToken = generateRefreshToken(claims.getSubject(), familyId, newRefreshId);

        return new TokenPair(newAccessToken, newRefreshToken);
    }

    public void revokeFamily(String familyId) {
        redis.delete(TOKEN_FAMILY + familyId);
    }
}

Refresh Token 轮换 vs 短期 Token

方案 安全性 用户体验 复杂度
单 Token 长期有效
Access Token 短期 + 无 Refresh ❌ 频繁登出
Access + Refresh 无轮换 ⚠️
Access + Refresh 轮换 + 重用检测

4.8 Token 绑定(HTB — Holder-of-Key)(补充)

防止 Token 被盗后在其他设备使用:

// 签发 Token 时绑定客户端指纹
public String issueBoundToken(User user, HttpServletRequest request) {
    String fingerprint = generateFingerprint(request);
    
    return Jwts.builder()
        .setSubject(user.getId().toString())
        .claim("fp_hash", sha256(fingerprint))  // 指纹哈希存入 Token
        .setExpiration(new Date(System.currentTimeMillis() + 900_000))
        .signWith(privateKey, SignatureAlgorithm.RS256)
        .compact();
}

// 验证时比对指纹
public Claims validateBoundToken(String token, HttpServletRequest request) {
    Claims claims = validateAndParse(token);
    String currentFp = sha256(generateFingerprint(request));
    String storedFp = claims.get("fp_hash", String.class);
    
    if (!currentFp.equals(storedFp)) {
        throw new SecurityException("Token 客户端指纹不匹配");
    }
    return claims;
}

// 生成客户端指纹(IP + UA 组合)
private String generateFingerprint(HttpServletRequest request) {
    String ip = request.getHeader("X-Forwarded-For");
    if (ip == null) ip = request.getRemoteAddr();
    String ua = request.getHeader("User-Agent");
    return ip + "|" + ua;
}

注意:指纹绑定会降低用户体验(移动网络 IP 频繁变化、UA 升级)。建议仅对高安全场景(银行、支付)启用。


总结

JWT 安全的核心不是选择了哪个库,而是验证逻辑是否正确

  1. 算法:永远只接受单一算法(RS256 或 ES256)
  2. 签名:永远用 parseClaimsJws(带 s),不用 parseClaimsJwt
  3. 异常:不能 catch(Exception e) 一把梭
  4. 过期:Access Token ≤ 15 分钟
  5. 吊销:配合 Refresh Token 轮换 + 重用检测
  6. 加密:敏感数据用 JWE 加密,或干脆不放 payload 里

系列文章

改变就是好事。