JWT快速入门与使用

文章目录

JWT(JSON Web Token)的使用

前言

本博文是对shiro使用JWT的完整补充拓展,提供里JWT详细使用的完整示例代码。

如果在阅读博文中对于RSA的理解和本人有不一致,请阅读本博文对于RSA解释的那部分,阅读完毕如有其它不一致意见,欢迎留言讨论。

JWT的作用和基本格式

只要阅读这篇问答就够了,里面详细解释了什么是JWT,JWT能用来干什么,JWT的格式,JWT是如何工作的,为什么要使用JWT等等一些列问题

基本格式

其实读完上面的文档,就了解了其基本格式。这里简单说一下,JWT分为三段

xxxxx.yyyyy.zzzzz

分别代表Header,Header里包含使用的签名算法,还有token的类型

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload,Payload里面主要放一些token相关信息如签发时间、过期时间,还有自定义的一些信息.较完整的payload示例如下

{
  "sub": "1000",
  "aud": [
    "https://app-one.com",
    "https://app-two.com"
  ],
  "nbf": 1638357246,
  "iss": "http://localhost:18080",
  "exp": 1638357846,
  "iat": 1638357246,
  "jti": "17fcfe5e-f705-481d-bf55-23368988f8d6",
  "scope": "read write"
}
  • iss 可以理解为生成token的地方
  • exp 过期时间
  • iat 可以理解为生成token时间
  • nbf 表示token的时间不得早于这个时间
  • aud 可以理解为这个token的作用范围
  • jti JWT ID
  • subject 为存储的信息,例如userId
  • scope为我们自定义的字段,如果想设置其他字段根本实际场景设置即可

完整解释见这里

Signature 是对于该JWT的签名.具体是对base64编码之后的Header和Payload+使用的加密算法进行加密
JWT快速入门与使用

最终输出的是base64编码的字符串,由三个点(·)分隔,所以JWT经过base64解码就可以看到对应的payload内容。所以payload里不建议放一些敏感信息。

为什么需要签名?

其实上面截图第二个标红的地方已经给出了解释。加签名是为了防止JWT被篡改,同时如果使用RSA私钥加密的话,那么只有在服务器端利用RSA的公钥解密才能得到正确的解密内容。利用这一点可以证明当前的JWT的签名不仅没被篡改过,还是当前服务器端签发的,这一点同样很重要(同理适用于HMAC,HMAC的secret也只在服务器端,计算结果一致说明该JWT确实来自自身server)。

JWT相关的库

在Java中用的比较多的JWT库是nimbus-jose-jwt和io.jsonwebtoken。可以在完整的JWT库列表中找到更多语言的JWT支持的库.

Shiro中使用JWT这一篇博文中,使用的io.jsonwebtoken的库。只是简单的贴了一些代码,并没有对类库的选择进行详细的区分。

本文将使用nimbus-jose-jwt库进行说明

nimbus-jose-jwt和jsonwebtoken的选择

*已经给出了答案,下面给出两个参考链接,可以结合自己的实际情况进行抉择

简而言之就是nimbus-jose-jwt支持的功能更丰富

nimbus-jose-jwt使用

nimbus-jose-jwt的官网中包含了大量的示例,根据这些示例可以快速入门使用JWT。在官网中,对于JWT的使用,nimbus基本分为了两部分JWS和JWE.本文将主要讲解JWS,JWE作为引导,读者可自行去探索实践

JWS

JSON Web Signature

对JWT进行签名。给内容加签名的作用前面已经说过了

  • 防止JWT内容被篡改,确保内容的完整性
  • 可以验证该JWT确实签发自当前server

注意:其实加签名不止在JWT中有应用,对于SSL的实际应用中,也用到了数字签名。有兴趣的话可以深入了解一下

在签名的加密算法上有HMAC、RSA、EC等.至于各个加密算法的使用场景,详细内容请移步官方文档,里面详细解释了加密数据的一些目标以及各种加密算法对应的目标和使用场景

HMAC加密算法
使用HMAC的场景

JWT快速入门与使用

比如邮件的验证码,第二个标红的地方解释了其最佳用途是:当数据要发送到外部,并且最终要被应用识别时。其核心思想是保证数据不被篡改,并且该数据是我们产生的。

这里使用一个简单springboot应用来进行演示

生成使用HMAC加密算法的jwt
@Configuration
@Component
public class SignerAndVerifierConfiguration {

    private static final String sharedSecret = "31611159e7e6ff7843ea4627745e89225fc866621cfcfdbd40871af4413747cc";
    @Bean(name = "HmacSigner")
    @SneakyThrows
    public JWSSigner generateHmacJwsSigner() {
        SecureRandom random = new SecureRandom();
        random.nextBytes(sharedSecret.getBytes());
        return new MACSigner(sharedSecret);
    }

    @Bean(name = "HmacVerifier")
    @SneakyThrows
    public JWSVerifier getHmacJwsVerifier() {
        SecureRandom random = new SecureRandom();
        random.nextBytes(sharedSecret.getBytes());
        return new MACVerifier(sharedSecret);
    }
}

首先配置HMAC的signer-JWSSigner和verifier-JWSVerifier,这里的HMAC的secret我们使用一个随机字符串

接下来开始构造JWT,首先构造我们的payload,主要使用==JWTClaimsSet.Builder()==方法

@Component
public class JWTClaimsSetFactory {

    public JWTClaimsSet buildJWTClaimsSet(String userId) {
        Calendar signTime = Calendar.getInstance();
        Date signTimeTime = signTime.getTime();
        signTime.add(Calendar.MINUTE, 10);
        Date expireTime = signTime.getTime();
        return new JWTClaimsSet.Builder()
                .issuer("http://localhost:18080")
                .subject(userId)
                .audience(Arrays.asList("https://app-one.com", "https://app-two.com"))
                .expirationTime(expireTime)
                .notBeforeTime(signTimeTime)
                .issueTime(signTimeTime)
                .jwtID(UUID.randomUUID().toString())
                .claim("scope", "read write")
                .build();
    }
}

拿到payload之后,加上Header,并使用signer进行加密即可,这里主要使用SignedJWT,表示我们使用的JWS

@RestController
@RequestMapping("generate")
@Log
public class GenerateTokenController {

    @Autowired
    @Qualifier("HmacSigner")
    private JWSSigner hmacSigner;

    @Autowired
    private JWTClaimsSetFactory jwtClaimsSetFactory;

    @GetMapping("hmac")
    @SneakyThrows
    public String generateHMACToken() {
      // 传入header 和 payload
        SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.HS256).type(JOSEObjectType.JWT).build(), jwtClaimsSetFactory.buildJWTClaimsSet("ADMIN"));
        // 进行签名
        signedJWT.sign(hmacSigner);
        String result = signedJWT.serialize();
        log.info("HMAC token is: \n" + result);
        return result;
    }
}
解析HMAC加密算法的jwt
@RestController
@RequestMapping("verify")
public class VerifyTokenController {

    @Autowired
    @Qualifier("HmacVerifier")
    private JWSVerifier hmacVerifier;

    @GetMapping("hmac")
    @SneakyThrows
    public boolean verifyHMACToken(@RequestHeader("Authorization") String token) {
        SignedJWT parse = SignedJWT.parse(token);
        if (!parse.verify(hmacVerifier)) {
            throw new RuntimeException("invalid token");
        }
        verifyClaimsSet(parse.getJWTClaimsSet());
        return true;
    }

    /**
     * 所有验证在这里进行.
     * @param jwtClaimsSet
     */
    private void verifyClaimsSet(final JWTClaimsSet jwtClaimsSet) {
        boolean result = false;
        if (Calendar.getInstance().getTime().before(jwtClaimsSet.getExpirationTime())) {
            result = true;
        }
        if (!result) {
            throw new RuntimeException("token expired");
        }
    }
}
RSA加密算法
使用RSA的场景

JWT快速入门与使用

例如OAuth2.0服务器下发access token的时候使用。使用的时候需要生成公钥和私钥,然后利用私钥签名,公钥进行验证。

在线生成RSA公钥和私钥

演示的时候利用在线生成RAS网站,生成RSA公钥和私钥。实际使用过程中,可以在服务器上利用openSSL进行生成

将生成的公钥和私钥,分别放入publish-key.pem和private-key.pem文件中,没有的话新建一个即可

生成使用RSA加密算法的jwt

和HMAC一样,我们也要配置signer和verifier

@Configuration
@Component
public class SignerAndVerifierConfiguration {

    private static final String sharedSecret = "31611159e7e6ff7843ea4627745e89225fc866621cfcfdbd40871af4413747cc";

    @Bean(name = "RsaSigner")
    @SneakyThrows
    public JWSSigner generateRsaJwsSigner(){
        // 读取私钥内容
        String pemEncodedRSAPrivateKey = PEMKeyUtils.readKeyAsString("rsa/private-key.pem");
        RSAKey rsaKey = (RSAKey) JWK.parseFromPEMEncodedObjects(pemEncodedRSAPrivateKey);
        return new RSASSASigner(rsaKey.toRSAPrivateKey());
    }

    @Bean(name = "RsaVerifier")
    @SneakyThrows
    public JWSVerifier getRsaJWSVerifier() {
      // 读取公钥内容
        String pemEncodedRSAPublicKey = PEMKeyUtils.readKeyAsString("rsa/publish-key.pem");
        RSAKey rsaPublicKey = (RSAKey) JWK.parseFromPEMEncodedObjects(pemEncodedRSAPublicKey);
        return new RSASSAVerifier(rsaPublicKey);
    }
}

这里将我们刚建好的两个文件放到工程的resources文件下,并在初始化signer和verifier的时候读取

@RestController
@RequestMapping("generate")
@Log
public class GenerateTokenController {

    @Autowired
    @Qualifier("RsaSigner")
    private JWSSigner rsaSigner;

    @Autowired
    private JWTClaimsSetFactory jwtClaimsSetFactory;

    @GetMapping("{userId}")
    @SneakyThrows
    public String generateRSAToken(@PathVariable String userId) {
      // header这里选择RS256
        SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JWT).build(), jwtClaimsSetFactory.buildJWTClaimsSet(userId));
      // 签名
        signedJWT.sign(rsaSigner);
        String result = signedJWT.serialize();
        log.info("token is: \n" + result);
        return result;
    }
}

这里构造payload的部分,示例中时一样的,就不再给出重复代码

解析RAS加密算法的jwt
@RestController
@RequestMapping("verify")
public class VerifyTokenController {

    @Autowired
    @Qualifier("RsaVerifier")
    private JWSVerifier rsaVerifier;

    @GetMapping
    @SneakyThrows
    public boolean verifyRSAToken(@RequestHeader("Authorization") String token) {
        SignedJWT parse = SignedJWT.parse(token);
        if (!parse.verify(rsaVerifier)) {
            throw new RuntimeException("invalid token");
        }
        verifyClaimsSet(parse.getJWTClaimsSet());
        return true;
    }
}

JWE

JWS是利用签名保证数据内容不会被篡改,但是最终的base64解码之后还是可以看到pauload其中的内容。就实际运用中来说,我们的JWT中也就放个userId。如果搭配OAuth2.0来的话,可能有scope等内容,但其实这些内容被解码之后对我们的影响也几乎没有。

不过,如果想对payload的内容加密,使得数据不会被解码出来,那么这时候就需要JWE-JSON Web Encryption。

这里再提醒一下:

  • JWS是对内容进行签名但是不加密
  • JWE是对内容进行加密但是不签名
使用RSA对内容进行加密
@Component
@Configuration
public class EncryptAndDecryptConfiguration {
    
    @Bean
    @SneakyThrows
    public JWEEncrypter generateRsaJweEncrypter() {
        String pemEncodedRSAPublicKey = PEMKeyUtils.readKeyAsString("rsa/publish-key.pem");
        RSAKey rsaPublicKey = (RSAKey) JWK.parseFromPEMEncodedObjects(pemEncodedRSAPublicKey);
        return new RSAEncrypter(rsaPublicKey);
    }
    
    @Bean
    @SneakyThrows
    public JWEDecrypter getRsaJweDecrypter() {
        String pemEncodedRSAPrivateKey = PEMKeyUtils.readKeyAsString("rsa/private-key.pem");
        RSAKey rsaKey = (RSAKey) JWK.parseFromPEMEncodedObjects(pemEncodedRSAPrivateKey);
        return new RSADecrypter(rsaKey);
    }
}

配置JWEEncrypter和JWEDecrypter

@RestController
@RequestMapping("secret")
@Log
public class SecretController {
    
    @Autowired
    private JWEEncrypter jweEncrypter;
    
    @Autowired
    private JWTClaimsSetFactory jwtClaimsSetFactory;
    
    @GetMapping("{userId}")
    @SneakyThrows
    public String secretToken(@PathVariable final String userId) {
      // 设置JWE header
        JWEHeader header = new JWEHeader(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A128GCM);
        EncryptedJWT encryptedJWT = new EncryptedJWT(header, jwtClaimsSetFactory.buildJWTClaimsSet(userId));
      // 使用publishKey进行加密
        encryptedJWT.encrypt(jweEncrypter);
        String result = encryptedJWT.serialize();
        log.info("encrypt token is: \n" + result);
        return result;
    }
}

再对生成的JWT的base64字符串进行解密,发现看不到payload的内容
JWT快速入门与使用

进行解密

    @GetMapping("decrypt")
    @SneakyThrows
    public void decryptRSASecretToken(@RequestHeader("Authorization") String token) {
        EncryptedJWT encryptedJWT = EncryptedJWT.parse(token);
       // 使用private进行解密
        encryptedJWT.decrypt(jweDecrypter);
    }

JWS + JWE

经过前面的介绍,有的人可能会想,我能不能又签名又加密呢,当然可以。这里可以参考nimbus官方关于签名和加密结合使用的示例。这里不做过多讲解

官方建议的顺序是先签名,再加密,至于为什么,看上述官方示例第一行会进行解释

关于RSA的一些解释

平时对RSA有过了解的同学,可能会对我JWS上述使用RSA的方式感到不解。具体疑惑可能如下:

  1. RSA非对称加密都是一对RSA公钥和私钥,为什么这里只有server端有公钥和私钥?

    答:下发token的server对应的是浏览器,浏览器本身并不需要对token进行解密,只需要下次请求放到请求头里带上就好了。所以这里浏览器端实际上不需要维护自己那一套RSA公钥和私钥的

  2. 为什么是使用RSA私钥加密,公钥解密。不都是公钥加密和私钥解密吗?

    答:对于公钥加密、私钥解密的场景是针对于内容加密的。而我们JWS那里使用的场景是对内容签名。所以在使用RSA实践中,我们要看我们的需求。简单解释如下

    第一种用法:公钥加密,私钥解密。—用于加解密
    第二种用法:私钥签名,公钥验签。—用于签名

    有点混乱,不要去硬记,总结一下:
    你只要想:
    既然是加密,那肯定是不希望别人知道我的消息,所以只有我才能解密,所以可得出公钥负责加密,私钥负责解密;
    既然是签名,那肯定是不希望有人冒充我发消息,只有我才能发布这个签名,所以可得出私钥负责签名,公钥负责验证。

    同一种道理,我在换种说法:
    私钥和公钥是一对,谁都可以加解密,只是谁加密谁解密是看情景来用的:
    第一种情景是签名,使用私钥加密,公钥解密,用于让所有公钥所有者验证私钥所有者的身份并且用来防止私钥所有者发布的内容被篡改.但是不用来保证内容不被他人获得。
    第二种情景是加密,用公钥加密,私钥解密,用于向公钥所有者发布信息,这个信息可能被他人篡改,但是无法被他人获得。

    比如加密情景:
    如果甲想给乙发一个安全的保密的数据,那么应该甲乙各自有一个私钥,甲先用乙的公钥加密这段数据,再用自己的私钥加密这段加密后的数据.最后再发给乙,这样确保了内容即不会被读取,也不会被篡改

    这段话非常简单明了的解释了公钥和私钥在不同场景下结合使用。这段话来自这位博主,非常感谢他做的解释

  3. 在OAtuh2.0中,Authorization Server下发token时使用的公钥还是私钥?

    答:其实上面的例子和第二问已经解释了。对于token的签名我们要使用私钥签名,才能保证这个签名来自于Authorization Server。同时,在OAuth2.0中,认真服务器负责下发token。那么判断这个token是否合法其实可以转移给其他Server来做,较少认证服务器的压力。那么这时候只需要把对应的公钥copy到解析token的服务器集群上即可。这一点也在RSA算法实现JWS那里截图标红的地方可以看到

  4. 有关数字签名和内容加密的内容推荐?

    答:可以在靠谱的网站上看一些解释,例如阮一峰的网站上应该由相关内容,虽然我没有去找过。或者多利用Google,少用或者不用Baidu

备注

上一篇:FastAPI登录实现(JWT)


下一篇:JWT工具类