jwt和jjwt解读以及应用

1,058 阅读10分钟

1. 基本概念解析

1.1 JWT (JSON Web Token)

JWT是一种开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息作为JSON对象。这些信息可以被验证和信任,因为它是数字签名的。JWT可以使用密钥(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名。

JWT的结构由三部分组成,以点(.)分隔:

  • Header(头部):指定签名算法和令牌类型
  • Payload(负载):包含声明(claims)
  • Signature(签名):用于验证消息未被篡改

1.1.1 JWT规范详解

JWT规范(RFC 7519)定义了以下核心内容:

  1. 令牌格式:JWT采用紧凑的、URL安全的字符串格式,便于在HTTP头部或URL参数中传输
  2. 声明(Claims)结构:定义了标准的声明名称和格式
  3. 签名和加密:规定了JWT可以通过JWS(JSON Web Signature)进行签名或通过JWE(JSON Web Encryption)进行加密
  4. 处理规则:规定了如何创建和验证JWT的规则

1.2 JJWT (Java JWT)

JJWT是一个Java库,提供了端到端的JWT创建和验证功能。它是为Java开发者设计的,使JWT规范的实现变得简单直观。JJWT的目标是易于使用和理解,同时提供必要的安全功能。

1.2.1 JJWT实现的JWT规范

JJWT实现了JWT规范的以下部分:

  1. JWT创建和解析:提供了流畅的API来创建和解析JWT
  2. JWS支持:支持使用各种算法(如HMAC、RSA、ECDSA)对JWT进行签名
  3. JWE支持:完整实现了JSON Web Encryption,用于创建和解析加密的JWT
  4. JWK支持:实现了JSON Web Key规范,用于表示和管理加密密钥
  5. 声明处理:支持所有标准声明和自定义声明
  6. 异常处理:提供了详细的异常类型,用于处理各种JWT验证错误
  7. 压缩支持:支持对JWT进行压缩,减小令牌大小
  8. Edwards曲线算法支持:支持EdDSA等现代加密算法

JJWT是一个完整的JOSE(JavaScript Object Signing and Encryption)规范实现,包括JWT、JWS、JWE、JWK等所有核心组件。

1.3 JWTS

JWTS是JJWT库中的一个类,提供了静态方法来创建和解析JWT。在JJWT库中,Jwts类是一个工厂类,提供了流畅的API来创建和验证JWT。

1.4 JWT vs JWTs

"JWT"和"JWTs"本质上指的是同一个概念,只是表达方式略有不同:

  • JWT (JSON Web Token):指的是单个令牌,强调的是技术概念
  • JWTs (JSON Web Tokens):指的是多个JWT令牌,通常在讨论JWT的集合或应用场景时使用

这两个术语在技术文档中经常互换使用,没有实质性的区别。

1.5 Payload与Claims

Payload是JWT的第二部分,包含了声明(Claims)。声明是关于实体(通常是用户)和其他数据的声明。

当Payload是JSON对象时,通常称为Claims。Claims是JWT中包含的键值对,用于传递信息。

1.6 Claim

Claim是JWT中的单个键值对,表示一个声明。例如,{"name": "John Doe"}中,"name"是键,"John Doe"是值,整体构成一个claim。

1.7 Registered Claims(注册声明)

JWT规范预定义了一组标准声明,这些声明不是强制的,但推荐使用:

  • Subject (sub):令牌的主题,通常是用户ID
  • Issuer (iss):令牌的发行者
  • Audience (aud):令牌的接收者
  • Expiration (exp):令牌的过期时间
  • Not Before (nbf):令牌生效的开始时间
  • Issued At (iat):令牌的发行时间
  • JWT ID (jti):令牌的唯一标识符

在JJWT中,不建议使用通用的claim方法来设置这些标准声明,而是使用专门的方法(如subject()issuer()等),这样可以提高代码的可读性。

1.8 Public Claims(公共声明)

Public Claims是可以由使用JWT的开发者自由定义的声明,但为了避免冲突,应该在IANA JSON Web Token Registry中注册或使用包含抗冲突命名空间的URI来定义。

例如:

{
  "https://example.com/claims/role": "admin"
}

这种方式确保了不同应用程序之间的声明名称不会冲突。

1.9 Private Claims(私有声明)

Private Claims是使用JWT的各方之间共同约定的声明,既不是注册声明也不是公共声明。这些声明用于在特定应用程序上下文中共享信息。

例如:

{
  "username": "john_doe",
  "employee_id": "E12345"
}

值得注意的是,subjectissuedAt等标准声明与自定义声明(如claim("name", "John Doe"))在内部都会合并到同一个Claims对象中。在下面的示例中:

String jwtToken = Jwts.builder()
    .subject("user123")
    .claim("name", "John Doe")
    .claim("admin", true)
    .issuedAt(Instant.now())
    .expiration(Instant.now().plus(1, ChronoUnit.HOURS))
    .signWith(key)
    .compact();

subject、自定义claims和issuedAt都会被合并到同一个Claims对象中,构成JWT的payload部分。

1.10 JWT的安全级别:JWS和JWE

JWT有三种安全级别:

  1. Unsecured JWT:不包含任何加密信息的JWT,仅由Header和Payload两部分组成,Header中的alg值为none。这种JWT不提供任何安全保障,不应在生产环境中使用。

  2. JWS (JSON Web Signature):带签名的JWT,由Header、Payload和Signature三部分组成。JWS确保数据在传输过程中没有被篡改(完整性),但不会加密数据本身,Payload中的信息对任何人都是可见的。这是最常用的JWT形式。

  3. JWE (JSON Web Encryption):加密的JWT,用于传输敏感信息。JWE对Payload进行加密,确保只有持有正确密钥的接收方才能查看内容(机密性)。当JWT需要传输敏感信息(如社会安全号码、银行账号等)时,应使用JWE。

在大多数应用场景中,JWS已经足够满足需求,因为JWT通常不用于传输高度敏感的信息,而是用于身份验证和授权。

2. 签名密钥的用法

2.1 在JJWT中创建签名密钥的方式

2.1.1 手动根据密钥字符串创建

SecretKey key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes());

这种方法需要开发者提供一个密钥字符串,然后将其转换为适合HMAC-SHA算法的SecretKey。这是一种常见的做法,特别是在Spring Boot应用中,密钥通常存储在配置文件中。

2.1.2 自动生成密钥

SecretKey key = Jwts.SIG.HS256.key().build();

这种方法会自动生成一个适合HS256算法的随机密钥。这对于测试或者一次性使用很方便,但在生产环境中,你需要保存这个密钥以便后续验证令牌。

2.2 自动生成密钥的保存策略

对于自动生成的密钥(如使用Jwts.SIG.HS256.key().build()或RSA密钥对),有几种保存策略:

2.2.1 密钥持久化

可以将生成的密钥序列化并保存到文件系统或数据库中:

// 生成密钥
SecretKey key = Jwts.SIG.HS256.key().build();

// 将密钥编码为Base64字符串
String encodedKey = Base64.getEncoder().encodeToString(key.getEncoded());

// 保存encodedKey到配置文件、数据库或安全的密钥存储
// ...

// 后续使用时,从存储中读取并还原
Byte[] decodedKey = Base64.getDecoder().decode(encodedKey);
SecretKey restoredKey = new SecretKeySpec(decodedKey, 0, decodedKey.length, "HmacSHA256");

2.2.2 RSA密钥对的保存

对于非对称加密(如RSA),需要保存公钥和私钥:

// 生成RSA密钥对
KeyPair keyPair = Jwts.SIG.RS256.keyPair().build();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

// 保存公钥和私钥
// ...

3. JJWT的重要概念与用法

3.1 JWT的创建

JJWT提供了流畅的API来创建JWT:

String jwtToken = Jwts.builder()
    .subject("user123")                                    // 设置主题(通常是用户ID)
    .claim("name", "John Doe")                           // 添加自定义声明
    .claim("roles", Arrays.asList("ROLE_USER", "ROLE_ADMIN")) // 添加复杂类型
    .issuer("myapp")                                     // 设置发行者
    .issuedAt(Instant.now())                             // 设置发行时间
    .expiration(Instant.now().plus(1, ChronoUnit.HOURS)) // 设置过期时间
    .signWith(key)                                       // 使用密钥签名
    .compact();                                          // 生成紧凑的JWT字符串

3.2 JWT的解析与验证

Claims claims = Jwts.parser()
    .verifyWith(key)           // 指定用于验证签名的密钥
    .build()                   // 构建解析器
    .parseSignedClaims(token)  // 解析并验证JWT
    .getPayload();             // 获取Claims

// 获取特定声明
String subject = claims.getSubject();
String name = claims.get("name", String.class);
List<String> roles = claims.get("roles", List.class);

3.3 异常处理

JJWT在解析和验证JWT时可能抛出多种异常,应当适当处理:

try {
    Claims claims = Jwts.parser()
        .verifyWith(key)
        .build()
        .parseSignedClaims(token)
        .getPayload();
    // 处理有效的令牌
} catch (ExpiredJwtException e) {
    // 处理过期的令牌
} catch (UnsupportedJwtException e) {
    // 处理不支持的JWT
} catch (MalformedJwtException e) {
    // 处理格式错误的JWT
} catch (SignatureException e) {
    // 处理签名验证失败
} catch (IllegalArgumentException e) {
    // 处理非法参数
}

3.4 压缩

JJWT支持对JWT进行压缩,减小令牌大小:

String compressedJwt = Jwts.builder()
    .subject("user123")
    // ... 其他声明 ...
    .compressWith(CompressionAlgorithms.DEFLATE) // 使用DEFLATE算法压缩
    .signWith(key)
    .compact();

4. 在Spring Boot中的实际应用

4.1 JWT配置类

@Component
@ConfigurationProperties(prefix = "jwt")
@Getter
@Setter
public class JwtConfig {
    private String secret;
    private long accessTokenExpiration;
    private long refreshTokenExpiration;
    private String tokenPrefix;
    private String headerString;
    private String issuer;
    
    // 可选:用于存储自动生成的密钥的Base64编码字符串
    private String generatedKeyBase64;
}

4.2 基于密钥字符串的JWT工具类

以下是一个使用手动指定密钥的JwtTokenUtil类,它是最常见的实现方式:

@Component
public class JwtTokenUtil {
    // 初始化密钥
    @PostConstruct
    public void init() {
        // 从配置中加载密钥
        secretKey = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8));
    }
    
    // 生成访问令牌
    public String generateAccessToken(UserDetails userDetails) {
        return generateToken(userDetails.getUsername(), jwtConfig.getAccessTokenExpiration());
    }
    
    // 验证令牌
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

4.3 使用自动生成密钥的JWT工具类

以下是一个使用自动生成密钥的JwtTokenUtil类,它演示了如何优雅地保存和获取生成的密钥:

@Component
public class AutoGenKeyJwtTokenUtil {

    private static final Logger logger = LoggerFactory.getLogger(AutoGenKeyJwtTokenUtil.class);
    
    @Autowired
    private JwtConfig jwtConfig;
    
    @Autowired
    private Environment environment;
    
    // 用于存储生成的密钥
    private SecretKey secretKey;
    
    // 应用启动时初始化密钥
    @PostConstruct
    public void init() {
        // 尝试从配置或文件加载密钥,如果不存在则生成新密钥
        secretKey = loadOrGenerateKey();
    }
    
    /**
     * 加载或生成密钥
     * 优先级:1. 配置文件中的generatedKeyBase64 2. 密钥文件 3. 新生成的密钥
     */
    private SecretKey loadOrGenerateKey() {
        // 1. 尝试从配置文件加载Base64编码的密钥
        if (StringUtils.hasText(jwtConfig.getGeneratedKeyBase64())) {
            try {
                byte[] decodedKey = Base64.getDecoder().decode(jwtConfig.getGeneratedKeyBase64());
                return new SecretKeySpec(decodedKey, 0, decodedKey.length, "HmacSHA256");
            } catch (Exception e) {
                logger.warn("无法从配置文件加载密钥,将尝试其他方式", e);
            }
        }
        
        // 2. 尝试从文件加载密钥
        SecretKey keyFromFile = loadKeyFromFile();
        if (keyFromFile != null) {
            return keyFromFile;
        }
        
        // 3. 生成新密钥并保存
        SecretKey newKey = Jwts.SIG.HS256.key().build();
        saveKeyToFile(newKey);
        
        // 4. 输出密钥的Base64编码,以便可以添加到配置文件中
        String encodedKey = Base64.getEncoder().encodeToString(newKey.getEncoded());
        logger.info("已生成新的JWT密钥,建议将其添加到配置文件中:jwt.generated-key-base64={}", encodedKey);
        
        return newKey;
    }
    
    /**
     * 从文件加载密钥
     */
    private SecretKey loadKeyFromFile() {
        File keyFile = getKeyFile();
        if (!keyFile.exists()) {
            return null;
        }
        
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(keyFile))) {
            Object object = ois.readObject();
            if (object instanceof SecretKey) {
                return (SecretKey) object;
            }
        } catch (Exception e) {
            logger.warn("从文件加载密钥失败", e);
        }
        return null;
    }
    
    /**
     * 将密钥保存到文件
     */
    private void saveKeyToFile(SecretKey key) {
        File keyFile = getKeyFile();
        try {
            // 确保目录存在
            keyFile.getParentFile().mkdirs();
            
            try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(keyFile))) {
                oos.writeObject(key);
            }
            // 设置文件权限(仅在非Windows系统上)
            if (!System.getProperty("os.name").toLowerCase().contains("win")) {
                Files.setPosixFilePermissions(keyFile.toPath(), 
                        PosixFilePermissions.fromString("rw-------"));
            }
        } catch (Exception e) {
            logger.error("保存密钥到文件失败", e);
        }
    }
    
    /**
     * 获取密钥文件对象,根据不同环境使用不同的文件名
     */
    private File getKeyFile() {
        String activeProfile = environment.getActiveProfiles().length > 0 ? 
                environment.getActiveProfiles()[0] : "default";
        String fileName = "jwt-key-" + activeProfile + ".dat";
        
        // 在生产环境中,应该使用更安全的位置存储密钥
        String dataDir = System.getProperty("user.home") + File.separator + ".myapp" + File.separator + "data";
        return new File(dataDir, fileName);
    }
    
    /**
     * 生成访问令牌
     */
    public String generateAccessToken(UserDetails userDetails) {
        return generateToken(userDetails.getUsername(), jwtConfig.getAccessTokenExpiration());
    }
    
    /**
     * 生成令牌
     */
    private String generateToken(String subject, long expiration) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);
        
        return Jwts.builder()
                .subject(subject)
                .issuedAt(now)
                .issuer(jwtConfig.getIssuer())
                .expiration(expiryDate)
                .id(UUID.randomUUID().toString())
                .signWith(secretKey)
                .compact();
    }
    
    /**
     * 从令牌中获取所有声明
     */
    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
    
    /**
     * 验证令牌
     */
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

4.4 配置文件示例

# application-jwt.yml
jwt:
  # 可选:手动指定的密钥(如果不使用自动生成的密钥)
  secret: secret_key_1234567890_abcdefg_123456_parade
  # 可选:自动生成的密钥的Base64编码(优先级高于secret)
  generated-key-base64: HhOtfHLz98ftVB0Oi/zgS8ZpRjZV2d9w+ld42Qmbnao=
  # 访问令牌过期时间(毫秒),默认30分钟
  access-token-expiration: 1800000
  # 刷新令牌过期时间(毫秒),默认7天
  refresh-token-expiration: 604800000
  # 令牌前缀
  token-prefix: "Bearer "
  # 请求头名称
  header-string: "Authorization"
  # 令牌发行者
  issuer: myapp