天天看点

16-Spring Security中的JOSE类库

前面对JOSE规范进行了科普,今天我们来实践一波。Java庞大的生态圈自然也少不了对JOSE的支持。

本文DEMO:https://gitee.com/felord/spring-security-oauth2-tutorial jose 分支。

Nimbus-JOSE-JWT

很多教程会使用​

​jjwt​

​作为JWT的集成类库,它很精湛,对于JWT应该是够用了。但是如果要结合OAuth2的话,它远远不够。这里我推荐使用connect2id开源的​

​nimbus-jose-jwt​

​,功能齐全,API友好。更重要的是最新的Spring Security 5.x和JOSE相关的都是用了该类库。

集成JOSE类库

这里我直接使用Spring Security二次封装的​

​spring-security-oauth2-jose​

​:

<dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-oauth2-jose</artifactId>
    </dependency>      

如果你集成了Spring Security OAuth2 Client类库将自动集成该JOSE依赖库。

生成公私钥文件

针对不同的算法可能有不同的生成方法,这里我使用keytool生成2048长度的RSA密钥证书,命令如下 :

keytool -genkey -alias jose  -keyalg RSA -storetype PKCS12 -keysize 2048 -validity 365 -keystore d:/keystores/jose.jks -storepass felord.cn  -dname "CN=(felord), OU=(felord), O=(felord), L=(zz), ST=(hn), C=(cn)"      

该命令会生成一个​

​jose.jks​

​​文件,​

​alias​

​​值为​

​jose​

​​,密码为​

​felord.cn​

​。它包含一对RSA公私钥。

我们还可以通过下面的命令提取出公钥文件​

​pub.cer​

​:

-export -alias jose -keystore d:/keystores/jose.jks  -file pub.cer      

传统方法中,这个公钥文件将提供给消费方进行验签操作。

JWK生成

从现在起传统方式可以放弃了。

加载秘钥

先要通过​

​java.security.KeyStore​

​加载秘钥,

= KeyStore.getInstance("jks");
        // 对应keytool命令中的 alias
        String alias = "jose";
        // 对应keytool命令中的 storepass
        String storePass = "felord.cn";
        char[] pin = storePass.toCharArray();
        // 借用Spring 读取资源的方法获取密钥文件流
        jks.load(new ClassPathResource("jose.jks").getInputStream(), pin);      

生成JWK

nimbusds库提供了​

​com.nimbusds.jose.jwk.RSAKey​

​来封装RSA算法的JWK,加载方法很简单。

= RSAKey.load(jks, alias, pin);
        System.out.println("privateJWK = " + rsaJwks.toJSONString());
        RSAKey publicJWK = rsaJwks.toPublicJWK();
        System.out.println("publicJWK = " + publicJWK.toJSONString());      

从公钥文件​

​pub.cer​

​中加载JWK也非常简单。

= CertificateFactory.getInstance("X.509");
        ClassPathResource resource = new ClassPathResource("pub.cer");
        InputStream inputStream = resource.getInputStream();
        X509Certificate certificate = (X509Certificate) certificateFactory.generateCertificate(inputStream);

        RSAKey publicKey = RSAKey.parse(certificate);      

这里我分别打印了RSA公私钥的JWK,我们来看看结构。

RSA公钥的JWK:

{
  "kty": "RSA",
  "x5t#S256": "SxqqdWYxT7BZrdH-uVpgAHfX2q34qPyxx4onX6mv-qI",
  "e": "AQAB",
  "kid": "jose",
  "x5c": [
    "MIIDazCCAlOgAwIBAgIEVt9AMjANBgkqhkiG9w0BAQsFADBmMQ0wCwYDVQQGEwQoY24pMQ0wCwYDVQQIEwQoaG4pMQ0wCwYDVQQHEwQoenopMREwDwYDVQQKEwgoZmVsb3JkKTERMA8GA1UECxMIKGZlbG9yZCkxETAPBgNVBAMTCChmZWxvcmQpMB4XDTIyMDMwMTA3MzAyNloXDTIzMDMwMTA3MzAyNlowZjENMAsGA1UEBhMEKGNuKTENMAsGA1UECBMEKGhuKTENMAsGA1UEBxMEKHp6KTERMA8GA1UEChMIKGZlbG9yZCkxETAPBgNVBAsTCChmZWxvcmQpMREwDwYDVQQDEwgoZmVsb3JkKTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAI+qB4H4/ORguG22+htrp8bewvFF/Ftzy5GBXq53HxoTahMwByYpFAaxyt+gyS1tqO4q9JSo/ZBN6pIifpH7lzXiKtbm+g5BO+QFcTEMrGMXbGqtGhzkJo2+GY06fkW0yGvDa0VNRS4CVwXHnxLUcAoUOUOL+hUZnVWW+VTkCt1wjQOv5jHx1V7Y/C9DE+g3kM4R0y+ZnSoG24eXg9AG9Im2sXshfNAfOzgRVG609ykJnh9G3lacB+8XGJhMjk7QVr6RoDUFwAOCHzQ0Lm5pqRy/RZFwPK7adw6pqmApV9eM4H279n0IMkAe9OHcrQ8JrG9MA6S30D4SZahbso6PFusCAwEAAaMhMB8wHQYDVR0OBBYEFAsX0KhMretoF88OMXjznl2sbN4XMA0GCSqGSIb3DQEBCwUAA4IBAQA4Ad1K9lyfmeQ8qwTFyRfQn5Q0qCG28+NWDWFWnT6Qh0LAloVVstogbOYq8WKi8Em3KxAg7sS+XEnKBx7z9SOeq3/+00L3hpjOhhbc+lyY5Gu7jH4xacahzIsQG+PGDlynyfreTmQQa/61fCgXliz7NOAteoEzHxm8dzHARw+97zIcg6r8JCA9RCzZCXR8AGAULgxZ65klt0lSqxnUdd+qKtphHw656XucxRMdH1g/CMUnXLlCrW9mAcyITOkBOb942zVZX9iH7KjkTkXLE/TgTYjucl5iVd6ysdfbrhsUXiKwSHejXRjZRg3vEPmObaEPoYkpr+rCzwI+3oguUibX"
  ],
  "n": "j6oHgfj85GC4bbb6G2unxt7C8UX8W3PLkYFerncfGhNqEzAHJikUBrHK36DJLW2o7ir0lKj9kE3qkiJ-kfuXNeIq1ub6DkE75AVxMQysYxdsaq0aHOQmjb4ZjTp-RbTIa8NrRU1FLgJXBcefEtRwChQ5Q4v6FRmdVZb5VOQK3XCNA6_mMfHVXtj8L0MT6DeQzhHTL5mdKgbbh5eD0Ab0ibaxeyF80B87OBFUbrT3KQmeH0beVpwH7xcYmEyOTtBWvpGgNQXAA4IfNDQubmmpHL9FkXA8rtp3DqmqYClX14zgfbv2fQgyQB704dytDwmsb0wDpLfQPhJlqFuyjo8W6w"
}      

私钥非常敏感,应禁止对外展示,因此这里我仅仅展示RSA私钥JWK结构:

{
  "p": "15cFgQp_8Sf3PZaFvVNbkj",
  "kty": "RSA",
  "x5t#S256": "SxqqdWYxT7BZrdH-uVpgAHfX2q34qPyxx4onX6mv-qI",
  "q": "qpeveODWsPrODDSIhKgy",
  "d": "fOyBMsfsQDrKpLzjp0xpzEiQg3U0B",
  "e": "AQAB",
  "kid": "jose",
  "x5c": [
    "MIIDazCCAlOgAwIBAgIEVt9AMjANBgkqhkiG9w0BAQsFADBmMQ0wCwYDVQQGEwQo"
  ],
  "qi": "kPG-qPAl472a0BIqGcCJq-VTxe",
  "dq": "ZguUkKdWZcmxpcVrAIeo2Bwf6G6bTm1Ock",
  "n": "j6oHgfj85GC4bbb6G2unxt7C8UX8W3PLkYFerncfGhNqEzA"
}      

我相信有人会对上面JSON中Key的感兴趣,在​​JOSE​​中已经对JWK公共部分的参数进行了解释,根据​​RFC7518​​提供的解读,我简单总结一下RSA JWK相关的特定参数:

16-Spring Security中的JOSE类库
再次强调任何算法的私钥都不应该公开访问。

JWK Set

JWK Set实际上就是一个JWK的集合。通过​

​com.nimbusds.jose.jwk.JWKSet​

​的构造方法初始化就可以了。

RSAKey rsaJwks = ...        
JWKSet jwkSet = new JWKSet(Collections.singletonList(rsaJwks));      

JWKSource

在nimbusds库的设计中,JWK并不能直接使用,需要提供一个可以检索匹配的的源,它被抽象为​

​com.nimbusds.jose.jwk.source.JWKSource​

​,它提供三种实现:

  • ​ImmutableJWKSet​

    ​​ 不可变的​

    ​JWKSet​

    ​。
  • ​ImmutableSecret​

    ​​ 由不可变的​

    ​SecretKey​

    ​​组成的​

    ​JWKSet​

    ​。
  • ​JWKSecurityContextJWKSet​

    ​​从​

    ​JWKSecurityContext​

    ​​加载​

    ​JWKSet​

    ​。
  • ​RemoteJWKSet​

    ​ 从URL请求中加载​

    ​JWKSet​

    ​。

JWK的使用

JWK只是密钥的另一种形态,其作用依然是签名/验签、加密/解密,只不过这里是给JWS、JWE提供该服务。 JWK借助于​

​NimbusJwtEncoder​

​就可以生成一个JWT。

<SecurityContext> jwkSource = jwkSource() 
        JwtEncoder jwtEncoder = new NimbusJwtEncoder(jwkSource);
        JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256)
                .jwkSetUrl("https://felord.cn/oauth2/jwks")
                .type("JWT")
                .build();

        Instant issuedAt = Clock.system(ZoneId.of("Asia/Shanghai")).instant();

        long exp = 604800L;
        Instant expiresAt = issuedAt.plusSeconds(exp);
        Instant notBefore = issuedAt.minusSeconds(60);

        JwtClaimsSet jwtClaimsSet = JwtClaimsSet.builder()
                .issuer("https://felord.cn")
                .subject("felord")
                .audience(Arrays.asList("https://client1.felord.cn",
                                        "https://client2.felord.cn"))
                .expiresAt(expiresAt)
                .issuedAt(issuedAt)
                .notBefore(notBefore)
                .id(UUID.randomUUID().toString())
                .claim("scope", Arrays.asList("message.read","message.write"))
                .build();

        JwtEncoderParameters parameters = JwtEncoderParameters
            .from(jwsHeader,jwtClaimsSet);
        Jwt jwt = jwtEncoder.encode(parameters);

        String token = jwt.getTokenValue();      

生成的JWT如下图:

16-Spring Security中的JOSE类库

很多同学不太清楚JWT中​

​Claims​

​参数代表的意思,你可以参考下图:

16-Spring Security中的JOSE类库

JwtDecoder

有编码就有解码,解码自然使用了公钥JWK。借助于​

​NimbusJwtDecoder​

​我们可以将JWT字符串转换为​

​Jwt​

​​对象。先构建一个​

​JwkSetUri​

​:

/**
     *  jwkSetUri端点,可以开放给特定的资源服务器
     *  
     * @return pub jwk
     */
    @SneakyThrows
    @GetMapping(value = "/oauth2/jwks")
    public Map<String, Object> jwks() {
        // 这里不重复RSAKey如何加载了
        // com.nimbusds.jose.jwk.RSAKey rsaKey = ... ;
        JWKSet jwkSet = new JWKSet(Collections.singletonList(rsaKey));
        // 这里只会输出公钥JWK
        return JSONObjectUtils.parse(jwkSet.toString());
    }      

​jwkSetUri​

​​的​

​MediaType​

​​可以为​

​application/jwk-set+json​

​​或者​

​application/json​

​。

如果设置了校验规则需要全部通过校验才能被解码

/**
     * 解析JWT
     */
    @SneakyThrows
    @Test
    public void jwtDecode() {
       final String token = "eyJ4NXQjUzI1NiI6IlN4cXFkV1l4VDdCWnJkSC11VnBnQUhmWDJxMzRxUHl4eDRvblg2bXYtcUkiLCJqa3UiOiJodHRwczpcL1wvZmVsb3JkLmNuXC9vYXV0aDJcL2p3a3MiLCJraWQiOiJqb3NlIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ." +
                "eyJzdWIiOiJmZWxvcmQiLCJhdWQiOlsiaHR0cHM6XC9cL2NsaWVudDEuZmVsb3JkLmNuIiwiaHR0cHM6XC9cL2NsaWVudDIuZmVsb3JkLmNuIl0sIm5iZiI6MTY0NjIzNjY2Miwic2NvcGUiOlsibWVzc2FnZS5yZWFkIiwibWVzc2FnZS53cml0ZSJdLCJpc3MiOiJodHRwczpcL1wvZmVsb3JkLmNuIiwiZXhwIjoxNjQ2ODQxNTIyLCJpYXQiOjE2NDYyMzY3MjIsImp0aSI6IjQ3OGNmZmRmLTllNWYtNDlhNy1iNjlkLWI3YzFhNzY1YTNiOCJ9." +
                "BEcV65GcRqwaaaRI1TUI2s5b7K6ewyV5-7g_OTWCBuS-WzdJX4v5kS5YkK-4ABwaQWZJgNsV-zOxWvXBICSqHocs-oKd40Iiqz6DWFY8RrfqN-HwphELbPLyfrIWcJ7iVr3t-vF3NWcLZaPuv0PGEn4n4mkdQXpu59FDxUgX-XR_i-kSZwgiw_NgLd7z0UpLlD3Cm3kxnwAFAPf_V1eQWjKhZvXYto4ws-j0lZSf1LGDDRu8d5WS4hPRt6h4-x9-ZPZIoxHifhrPfVG3qQUZ0MlA1mKqfcrVUexgFqN8bcTP4krkwDbodsYVqQPHKFMWaIPHcLvHYp5_hkuzxCBT7A";

        // JwkController   需要远程端点支持。
        NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder
                .withJwkSetUri("http://localhost:8080/oauth2/jwks")
                .build();
        Jwt jwt = jwtDecoder.decode(token);
        Assertions.assertEquals("felord",jwt.getSubject());
        Assertions.assertEquals("https://felord.cn",jwt.getIssuer().toString());
    }      

继续阅读