JWT JWS JWE & Spring 集成

1,526 阅读4分钟

最近开始研究 OAuth2.0 协议,发现大部分都基于 JWT(JSON Web Token) 去实现,放弃了传统的 cookie/session 模式。cookie/session 模式不仅需要前端存储 cookie,后端服务也需要存储对应的 session,在分布式场景下,就需要依赖于集中存储去维护 session。而 JWT 则无需维护,在 token 中已经存储了用户相关的信息。

JWT JWS JWE

JWT

JSON Web 令牌(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息

也就是说 JWT 实际上是一种规范,并确定使用 JSON 作为表达,JWS 和 JWE 则是对这种规范的实现以及增强。

JWS (JSON Web Signature)

JWS 使用 Base64 进行编码,它包含三个部分,分别以 . 进行分割,如xxxx.yyyy.zzz的形式,分别对应 Header, Payload, Signature。

  • Header: 存储一些元数据:类型以及其签名使用的算法
  • Payload:按照标准存储相关信息,包括 iss-签发者,exp-过期时间,sub-对象主体(一般是用户信息),iat-签发时间,aud-接收方
  • Signature:签名信息,由密钥签署,可以使用公钥来验证。

JWS 一般用于交换非敏感信息,若 token 中包含用户敏感信息,则需要对其加密,这就用到了 JWE

JWE(JSON Web Encryption)

JWE 本质上是对 Jwt 中的 Payload 进行加密。当然 JWE 是可以包含 JWS 的,也就是说当前 token 既有签名保证完整性,又有加密来保证安全性。

既然要加密,则需要公钥和私钥了,密钥一般使用 OpenSSL 生成 x509 格式。这里又引伸出 JWKs 的概念:

JSON Web 密钥集(JWKS)包含公钥,用于验证授权服务器发布并使用 RS256 签名算法签名的 JWT。

JWKs 包含一系列的公钥,授权服务器可以通过接口对外暴露相关的公钥,用于资源服务器进行认证。更多的 JWK 规范可以参考self-issued.info/docs/draft-…

Spring OAuth2 集成

资源服务器(Resource Server)

根据上一篇文章,可以直接引用 spring-boot-starter-oauth2-resource-server 依赖。首先需要配置一个获取 JWK 的接口:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://localhost:8080/.well-known/jwks.json

这个接口的返回数据如下:

{
  "keys": [
    {
      // key 使用的算法族(RSA/EC)
      "kty": "RSA",
      "e": "AQAB",
      // 用途: sig-签名 enc-加密
      "use": "sig",
      // 自定义的 key id,用于验证解密方使用
      "kid": "jwt-test",
      // 加密算法
      "alg": "RS256",
      // 公钥
      "n": "kyGzFh-nlzeYoTfmi3Tn0zhX3b2dL7OBczVVLornTg7SlmIC7xbx-A8t5HZebCfwesseKrfYO1J__bgsFJzVnDSMKlpyGVSoyGAKK46MUgDd_6_MmH_S2x4JcmOOdryw1zghpMXMO8C7i7fKdWr8hvQfxeoj0rK9A37Mtywlu2ur1GxEmUuiGEufOpiHhIldyJmEK5K4wD7woAoko6NvYx4kR-uGCij_RuyW_4_a737t4I57Ab50RoCknLJUj2_q355A0q-LANiskfRYUDGsqSxGX_0l7O7u_kU-GicY0mUsfC19nQ6MkzcTGcIbxhdLmIUGmSLZ3AFI4EyyfvtEhw"
    }
  ]
}

有一点需要注意,JWK 其实是一组公钥的集合,key id 可以支持密钥在数据库中存储或更新迭代。在授权服务器中,暴露出来的应该是仅用于验证的公钥。

在配置类里,简单的将 JWT 解析方法配置进去就好。

@Configuration
@EnableWebSecurity(debug = true)
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
    private String jwkSetUri;

    @Override
    protected void configure(HttpSecurity http) throws Exception{
        http
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/hello/**").hasAuthority("SCOPE_all")
                .anyRequest().authenticated();
    }

    @Bean
    JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri).build();
    }
}

授权服务器

授权服务器相对要麻烦一些,首先要定义 JWT 的一系列操作,这里用到的是nimbus-jose-jwt 依赖(spring-boot-starter-oauth2-resource-server也是引用了此jar包)。

@Configuration
public class JwtConfig {
    /**
     *  配置 接口返回的 JWKs 的参数, 包括用途、keyId、算法族。
     */
    @Bean
    public JWKSet jwkSet() {
        RSAKey.Builder builder = new RSAKey.Builder((RSAPublicKey) keyPair().getPublic())
                .keyUse(KeyUse.SIGNATURE)
                .algorithm(JWSAlgorithm.RS256)
                .keyID("jwt-test");
        return new JWKSet(builder.build());
    }
    /**
     * 加载由 openssl 生成的证书,需要 jks 格式。
     */ 
    @Bean
    public KeyPair keyPair(){
        ClassPathResource ksFile = new ClassPathResource("jwt-test.jks");
        KeyStoreKeyFactory ksFactory =
                new KeyStoreKeyFactory(ksFile, "jwt-test".toCharArray());
        return ksFactory.getKeyPair("jwt-test");
    }
    /**
     * Spring oauth2 需要的 token转换器。
     */ 
    @Bean
    public JwtAccessTokenConverter converter(){
        Map<String, String> customHeaders =
                Collections.singletonMap("kid", "jwt-test");
        return new CustomJwtAccessConvertor(
                customHeaders,
                keyPair());
    }
    @Bean
    public TokenStore jwtTokenStore(){
        return new JwtTokenStore(converter());
    }

}

此外,JWT 的生成步骤也需要重写,这里是继承了 JwtAccessTokenConvertor 重写encode()方法,并塞入我们自己生成的 Jwt header。


@Override
    protected String encode(OAuth2AccessToken accessToken,
                            OAuth2Authentication authentication) {
        String content;
        try {
            content = this.objectMapper
                    .formatMap(getAccessTokenConverter()
                            .convertAccessToken(accessToken, authentication));
        } catch (Exception ex) {
            throw new IllegalStateException(
                    "Cannot convert access token to JSON", ex);
        }
        return JwtHelper.encode(
                content,
                this.signer,
                this.customHeaders).getEncoded();
    }