阅读 695

JWT(auth0):RS256非对称加密算法实现Token的签发、验证

head.PNG

前言

 日常开发中,客户端与服务器通常采用HTTP协议进行通信,但HTTP是没有状态的,无法记录用户的身份信息和行为。

会话.PNG

 会话跟踪技术是一种在客户端与服务器间保持HTTP状态的解决方案,我们所熟知的有Cookie + Session、URL重写、Token等。

 Cookie在浏览器保存SessionID、Session实际内容保存在服务端,目前的项目都是前后端分离 + 微服务,所以会面临Session共享问题,随着用户量的增多,开销就会越大。URL重写又是通过明文传输,不安全容易被劫持。

Token的优势:

  • Token支持跨域访问,Cookie不可以跨域访问。
  • Token支持多平台,Cookie只支持部分web端。

What is JWT ?

 JWT的全称是Json Web Token,是一种基于JSON的、用于在网络上声明某种主张的令牌(token)规范。

 官方解释:
官方解释

JWT由三部分组成:hand、payload、signature,各部分通过 ‘ . ’ 连接

xxxx . yyyy . zzzz

1、HEAD

 头部是一个JSON对象,存储描述数据类型(JWT)和签名算法(HSA256、RSA256),通过Base64UrlEncode编码后生成head 。

编码:
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9

解码:
{
  "alg": "RS256",
  "typ": "JWT"
}
复制代码

2、PAYLOAD

 负载存放一些传输的有效声明,可以使用官方提供的声明,也可以自定义声明。同样通过Base64UrlEncode编码后生成payload。声明可以分为三种类型:

  1. Registered claims:

 官方预定义的、非强制性的但是推荐使用的、有助于交互的声明(注意使用这些声明只能是三个字符)。

名称作用
iss (issuer)签发人
sub (subject)主题
aud (audience)受众
exp (expiration time)过期时间
nbf (Not Before)生效时间
iat (Issued At)签发时间
jti (JWT ID)编号
  1. Public claims:

 保留给JWT的使用者自定义。但是需要注意避免使用IANA JSON Web Token Registry中定义的关键字。

  1. Private claims:

 保留给JWT的使用者自定义,用来传送传输双方约定好的消息。((—_—)是不是没搞懂public claims和private claims的区别,阿浪也不知道)

编码:
eyJhdWQiOiLopb_pl6jpmL_mtaoiLCJkYXRhIjoiXCLopb_pl6jpmL_mtapcIiIsImlzcyI6IkhBTkdIVUFfQURNSU4iLCJleHAiOjE2MjIzNjA4NDEsImlhdCI6MTYyMjM2MDg0MX0

解码:
{
  "aud": "西门阿浪",
  "data": "\"西门阿浪\"",
  "iss": "HANGHUA_ADMIN",
  "exp": 1622360841,
  "iat": 1622360841
}
复制代码

3、SIGNATURE

 数据签名是JWT的核心部分,构成较为复杂,且无法被反编码。

HS256加密:
signature = HMACSHA256( base64UrlEncode(header) + "." +base64UrlEncode(payload), secret );
			
RS256加密:      
signature = RSASHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload), publicKey, privateKey)
复制代码

signature可以选择对称加密算法或者非对称加密算法,常用的就是HS256、RS256。

  • 对称加密: 加密方和解密方利用同一个秘钥对数据进行加密和解密。

  • 非对称加密: 加密方用私钥加密,并把公钥告诉解密方用于解密。

插言.PNG

4、JWT执行逻辑

 逻辑清晰明了,用户首次登陆时,通过传输账号密码验证身份,验证成功后,服务器生成Token响应用户。用户后续请求只需要传送Token,服务器只需对Token进行校验来确认身份。

双Token 保证 活跃用户

活跃用户

 Token用于身份认证时,如果有效期设置太长,泄露了会不安全。如果设置太短,用户频繁的重新登陆,程序员的祖坟有可能不保。那如何界定有效时间呢?这就要引入一个概念:用户的活跃性。

 系统把用户分为活跃用户和不活跃用户,对于不活跃用户,token过期后需要重新登陆,因为使用频率较低,token失活后重新登陆,他的感受没有那么强烈。

 活跃用户在token过期后,不应该直接登陆,而是要根据他的活跃时间来判定是否重新激活token,当符合条件时,直接激活Token,带给用户最好的体验。

若token有效期时长为at,活跃用户时长计为rt,且用户每次操作客户端后活跃时间都与之同步刷新。

  • 当 rt == at时

这种情况,当然可以确定属于活跃用户。在整个token的有效期用户都在操作,如果这时token失效让重新登陆,用户体验确实不好。

  • 假设存在 rt > at的情况

既然rt与at相等时,属于活跃用户,这种可以算是激进分子了。

  • 当 rt < at时

这种情况比较复杂,我们无法界定rt在at中所占比例为多少时属于活跃用户,而且我们也无法推测token失效后,用户啥时候再次请求,因此定义为不活跃用户。

accessToken、refreshToken两兄弟

用户首次登陆后,获得accessToken(时长较短)和refreshToken(时间较长),每次请求判断accessToken是否过期。

当access_token过期后,判断refreshToken是否过期,若没过期,则通过refreshToken刷新获取新的access_token,如果都过期,就需要重新登录了。

由活跃用户分析可知,当 rt >= at,用户在at时间内都是活跃的。设accessToken的有效期为用户的活跃时间rt,当rt <= refresh_Time,直接刷新rt。所以可以认为 [accessToken创建开始时间点 ,2 * accessToken有效时长 ] 时间内用户是活跃的

建议:refreshToken时间 >= 2 * accessToken时间。

auth0大法

JWT只是规范,就像Java中的接口,无法直接使用,需要一个实现规范的具体实现库。平时开发中较多使用jjwt,据传auth0的底层实现效率更高。注意,auth0不是OAuth2,不要搞混了。

首先,加入maven依赖,最新版本就是3.16.0。

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.16.0</version>
</dependency>
复制代码

HS256算法

HS256是对称加密算法,相对来说比较简单易上手,网上例子也很详尽,感兴趣可以自己查找资料。我们主要来看看非对称加密算法。

RS256算法

  • 1、生成密钥对

想签发Token,首先要生成PublicKey和PrivateKey。JDK的java.security. interfaces包提供了RS算法的密钥对类型。我们直接构建一个存方密钥对的POJO类。

public class RSA256Key {

    private RSAPublicKey publicKey;
    private RSAPrivateKey privateKey;

    public RSA256Key() {
    }

    public RSA256Key(RSAPublicKey publicKey, RSAPrivateKey privateKey) {
        this.publicKey = publicKey;
        this.privateKey = privateKey;
    }
}
***省略getter和setter***
复制代码

 然后写一个密钥生成的工具类,通过官方信息可知,密钥对的实例生成后可重复使用。因此,我打算采用单例的双重校验锁来控制密钥对象的生成。

如果并发量过大的话,自己可以加一个自定义线程池去生成。

    //数字签名
    public static final String KEY_ALGORITHM = "RSA";

    //RSA密钥长度
    public static final int KEY_SIZE = 1024;

    //唯一的密钥实例
    private static volatile RSA256Key rsa256Key;

  /**
     * 生成 公钥/私钥
     *
     *  由双重校验锁保证创建唯一的密钥实例,因此创建完成后仅有唯一实例。
     *  当被JVM回收后,才会创建新的实例
     * @return
     * @throws NoSuchAlgorithmException
     */
    public static RSA256Key generateRSA256Key() throws NoSuchAlgorithmException {

        //第一次校验:单例模式只需要创建一次实例,若存在实例,不需要继续竞争锁,
        if (rsa256Key == null) {
            //RSA256Key单例的双重校验锁
            synchronized(RSA256Key.class) {
                //第二次校验:防止锁竞争中自旋的线程,拿到系统资源时,重复创建实例
                if (rsa256Key == null) {
                    //密钥生成所需的随机数源
                    KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(KEY_ALGORITHM);
                    keyPairGen.initialize(KEY_SIZE);
                    //通过KeyPairGenerator生成密匙对KeyPair
                    KeyPair keyPair = keyPairGen.generateKeyPair();
                    //获取公钥和私钥
                    RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
                    RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
                    rsa256Key = new RSA256Key();
                    rsa256Key.setPublicKey(publicKey);
                    rsa256Key.setPrivateKey(privateKey);
                }

            }
        }
        return rsa256Key;
    }
复制代码

单例的双重校验锁能够严格保证RSAPublicKey对象生成的唯一性,当线程们进入generateRSA256Key()方法验证实例对象为空时,最快的线程拿到锁资源,并阻塞后续线程。

KeyPairGenerator是密钥生成的核心类,根据我们自定义的密钥长度KEY_SIZE来生成密钥。密钥生成创建RSA256Key实例对象时,此处有个坑(当然是并发量足够大时),希望有大佬指点:虽然synchronized阻塞住了部分线程,但当RSA256Key实例化后还未赋值前,正巧有新线程刚检测rsa256Key,直接跳到后续逻辑,因为密钥实例值为空报出异常

  • 2、签发Token

 Token的签发逻辑很简单,auth0为我们封装的很好,只需要向Algorithm的静态方法RSA256传递私钥,通过JWT类内的withXXX()方法传参即可。

 /**
     * 签发Token
     *
     * withIssuer()给PAYLOAD添加一跳数据 => token发布者
     * withClaim()给PAYLOAD添加一跳数据 => 自定义声明 (key,value)
     * withIssuedAt() 给PAYLOAD添加一条数据 => 生成时间
     * withExpiresAt()给PAYLOAD添加一条数据 => 保质期
     *
     * @param data
     * @return
     * @throws NoSuchAlgorithmException
     */
    public static String creatTokenByRS256(Object data) throws NoSuchAlgorithmException {
        //初始化 公钥/私钥
        RSA256Key rsa256Key = SecretKeyUtil.generateRSA256Key();

        //加密时,使用私钥生成RS算法对象
        Algorithm algorithm = Algorithm.RSA256(rsa256Key.getPrivateKey());

        return JWT.create()
                //签发人
                .withIssuer(ISSUER)
                //接收者
                .withAudience(data.toString())
                //签发时间
                .withIssuedAt(new Date())
                //过期时间
                .withExpiresAt(DateUtil.addHours(2))
                //相关信息
                .withClaim("data", JsonUtil.toJsonString(data))
                //签入
                .sign(algorithm);
    }
复制代码

  • 有个值得吐槽的一点(下面是对auth0的源码分析,不感兴趣的可以跳过

    /**
     * Creates a new Algorithm instance using SHA256withRSA. Tokens specify this as "RS256".
     *
     * @param key the key to use in the verify or signing instance.
     * @return a valid RSA256 Algorithm.
     * @throws IllegalArgumentException if the Key Provider is null.
     * @deprecated use {@link #RSA256(RSAPublicKey, RSAPrivateKey)} or {@link #RSA256(RSAKeyProvider)}
     */
    @Deprecated
    public static Algorithm RSA256(RSAKey key) throws IllegalArgumentException {
        RSAPublicKey publicKey = key instanceof RSAPublicKey ? (RSAPublicKey) key : null;
        RSAPrivateKey privateKey = key instanceof RSAPrivateKey ? (RSAPrivateKey) key : null;
        return RSA256(publicKey, privateKey);
    }
    
    /**
     * Creates a new Algorithm instance using SHA256withRSA. Tokens specify this as "RS256".
     *
     * @param publicKey  the key to use in the verify instance.
     * @param privateKey the key to use in the signing instance.
     * @return a valid RSA256 Algorithm.
     * @throws IllegalArgumentException if both provided Keys are null.
     */
    public static Algorithm RSA256(RSAPublicKey publicKey, RSAPrivateKey privateKey) throws IllegalArgumentException {
        return RSA256(RSAAlgorithm.providerForKeys(publicKey, privateKey));
    }
 

复制代码

 因为我们是使用的RSAPublicKey和RSAPrivateKey存储的密钥,而且两种类型都继承自RSAKey,所以我们可以直接调用RSA256(RSAKey key),只需传入私钥,逻辑会自动为公钥赋null,顺序调用第二个方法。

但是该方法标记了@Deprecated,说明官方废除了这方法。我们只能直接调用第二个方法,所以传参需要我们自己指定null值,而且有些不了解RS256算法的人, 会同时传入公钥与私钥。

     /**
     * Creates a new Algorithm instance using SHA256withRSA. Tokens specify this as "RS256".
     *
     * @param keyProvider the provider of the Public Key and Private Key for the verify and signing instance.
     * @return a valid RSA256 Algorithm.
     * @throws IllegalArgumentException if the provided Key is null.
     */
    public static Algorithm RSA256(RSAKeyProvider keyProvider) throws IllegalArgumentException {
        return new RSAAlgorithm("RS256", "SHA256withRSA", keyProvider);
    }
复制代码

 通过调用上面两个方法生成RSAKeyProvider,调入该方法,最终生成Algorithm对象。

  • 3、校验Token

 校验与签发同样简单,只是通过PublicKey生成Algorithm,因为我把加密解密都放在了服务端,省去了很多不必要的麻烦。

  public static boolean verifierToken(String token) throws NoSuchAlgorithmException {

        //获取公钥/私钥
        RSA256Key rsa256Key = SecretKeyUtil.generateRSA256Key();

        //根据密钥对生成RS256算法对象
        Algorithm algorithm = Algorithm.RSA256(rsa256Key.getPublicKey());

        System.out.println("PublicKey: " + rsa256Key.getPublicKey().getPublicExponent());

        //解密时,使用gong钥生成算法对象
        JWTVerifier verifier = JWT.require(algorithm)
                                    .withIssuer(ISSUER)
                                    .build();

        try {
            //验证Token,verifier自动验证
            DecodedJWT jwt = verifier.verify(token);
            return true;
        }catch (JWTVerificationException e){
            log.error("Token无法通过验证! " + e.getMessage());
            return false;
        }
复制代码

通过JWTVerifier对象可生成DecodedJWT,如果想获取具体的TOken信息,可通过DecodedJWT获取。

对auth0底层实现感兴趣的同学,可以从gitHub上clone下来自己跑起来看一看。学一学别人源码中的设计模式和逻辑处理。

源码:github.com/auth0/java-…

文中所涉及的代码在我的GitHub中:西门阿浪


创作不易,如果觉得有帮助,希望点个👍,关注阿浪!
公众号:【西门阿浪】

GitHub:github.com/zhaojie777

文章分类
后端
文章标签