zoukankan      html  css  js  c++  java
  • spring cloud实战与思考(五) JWT之携带敏感信息

    需求:

    需要将一些敏感信息保存在JWT中,以便提高业务处理效率。

      众所周知JWT协议RFC7519使用Base64UrlHeaderPayloadJson字符串进行编解码。A JWT is represented as a sequence of URL-safe parts separated by period ('.') characters. Each part contains a base64url-encoded value.

      不幸地的是Base64Url编码方式不是加密手段。任何得到JWT的人都可以使用公开的解码方式将JWT的原文解析出来。所以很多介绍JWT的文章都提醒不要在JWT中携带敏感信息。但是在特定业务场景下,如果JWT中含有某些关键信息,就可以节省后台很多额外操作,例如数据库查询、服务接口访问等。进而缩短后台响应时间,改善用户体验。

      既然在JWT中携带敏感信息能带来这么大的好处,那么花点精力实现这个功能看起来是值得的。提到敏感信息的保密,自然会想到加密和解密。将敏感信息的密文放入JWT中,即使JWT泄露,由于没有密钥,获得JWT的人也无法对其进行解密。而服务器端只要增加一个解密过程就能提取出敏感信息,提高后续业务处理效率。

      加密算法主要分两大类:对称加密和非对称加密。因为JWT是由服务端创建,客户端转手后又发回服务端使用。所以加密和解密都发生在服务端,不涉及到密钥的分发,相较其他加密场景要简单很多。所以我选择了加解密运算速度快的对称加密算法AES作为敏感信息的加密方式。

      项目中使用JJWT java库创建和校验JWT,将AES加解密过程放入对JJWT封装的接口中。复用创建JWT的数字签名密钥作为AES的加密密钥和初始向量。加解密过程对调用者是透明的。

      Maven依赖:

            <!--Java JWT 依赖库-->
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt</artifactId>
                <version>0.9.0</version>
            </dependency> 

      JJWT封装接口:

    import io.jsonwebtoken.JwtBuilder;
    import io.jsonwebtoken.Jwts;
    import io.jsonwebtoken.SignatureAlgorithm;
    
    import javax.crypto.spec.SecretKeySpec;
    import javax.xml.bind.DatatypeConverter;
    import java.security.Key;
    import java.util.Date;
    import java.util.HashMap;
    import java.util.Map;
    
    public class JWTUtil {
        /**
         * 生成JWT签名字符串
         *
         * @param publicClaims      无需加密保持明文方式的JWT claims
         * @param privateClaims:   需要加密的JWT claims
         * @param ttlMillis:          JWT过期时长(毫秒)
         * @param key:             JWT HS256签名的密钥,也是AES加密的密钥
         *
         * @return JWT字符串
         *
         */
        public static String createJWT(Map<String, Object> publicClaims, Map<String, Object> privateClaims,long ttlMillis, String key) {
    
            SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
            long nowMillis = System.currentTimeMillis();
    
            byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(key);
            Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());
    
            if(null != privateClaims && !privateClaims.isEmpty()) {
                String jsonStr = JsonUtil.map2JsonStr(privateClaims);
                //使用同一个密钥对私有声明进行加密
                String encrypteClaims = AesEncryptUtil.encrypt(jsonStr, key, key);
                if(null != encrypteClaims) {
                    if(null == publicClaims) {
                        publicClaims = new HashMap<>();
                    }
                    publicClaims.put("privateClaims", encrypteClaims);
                }
            }
    
            JwtBuilder builder = Jwts.builder().signWith(signatureAlgorithm, signingKey);
            if(null != publicClaims && publicClaims.size() > 0) {
                builder.setClaims(publicClaims);
            }
    
            if (ttlMillis >= 0) {
                long expMillis = nowMillis + ttlMillis;
                Date exp = new Date(expMillis);
                builder.setExpiration(exp);
            }
    
            return builder.compact();
        }
    
        /**
         * 解析并校验JWT, 校验过程是JJWT内部实现的,会校验JWT是否过期,是否被篡改。
         *
         * @param jwt     JWT字符串
         * @param key:   JWT HS256签名的密钥,也是AES加密的密钥
         *
         * @return JWT claims的Map对象
         *
         */
        public static Map<String, Object> parseJWT(String jwt, String key){
    
            Map<String, Object> privateMap = null;
    
            //parser函数会在参数缺失、校验失败、token过期等情况下抛出runtime异常,所以调用者需要捕获该runtime异常
            Map<String, Object> originalMap = Jwts.parser()
                    .setSigningKey(DatatypeConverter.parseBase64Binary(key))
                    .parseClaimsJws(jwt).getBody();
    
            //解析私有声明
            if(null != originalMap && originalMap.containsKey("privateClaims")) {
                String encryptedStr = (String)originalMap.get("privateClaims");
                if(null != encryptedStr && !encryptedStr.isEmpty()) {
                    String decryptedStr = AesEncryptUtil.decrypt(encryptedStr, key, key);
                    if(null != decryptedStr && !decryptedStr.isEmpty()) {
                        privateMap = JsonUtil.jsonStr2Map(decryptedStr);
                    }
                }
    
                originalMap.remove("privateClaims");
                if(null != privateMap && privateMap.size() > 0) {
                    originalMap.putAll(privateMap);
                }
            }
    
            return originalMap;
        }
    }

      AES加密和解密接口:

     

    import javax.crypto.Cipher;
    import javax.crypto.spec.IvParameterSpec;
    import javax.crypto.spec.SecretKeySpec;
    import java.io.UnsupportedEncodingException;
    
    public class AesEncryptUtil {
        private static final String encode = "UTF-8";
        private static final String mode = "AES/CBC/PKCS5Padding";
    
        /**
         * JDK只支持AES-128加密,也就是密钥长度必须是128bit;参数为密钥key,key的长度小于16字符时用"0"补充,key长度大于16字符时截取前16位
         **/
        private static SecretKeySpec get128BitsKey(String key) {
            if (key == null) {
                key = "";
            }
            byte[] data = null;
            StringBuffer buffer = new StringBuffer(16);
            buffer.append(key);
            //小于16后面补0
            while (buffer.length() < 16) {
                buffer.append("0");
            }
            //大于16,截取前16个字符
            if (buffer.length() > 16) {
                buffer.setLength(16);
            }
            try {
                data = buffer.toString().getBytes(encode);
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
            return new SecretKeySpec(data, "AES");
        }
    
        /**
         * 创建128位的偏移量,iv的长度小于16时后面补0,大于16,截取前16个字符;
         *
         * @param iv
         * @return
         */
        private static IvParameterSpec get128BitsIV(String iv) {
            if (iv == null) {
                iv = "";
            }
            byte[] data = null;
            StringBuffer buffer = new StringBuffer(16);
            buffer.append(iv);
            while (buffer.length() < 16) {
                buffer.append("0");
            }
            if (buffer.length() > 16) {
                buffer.setLength(16);
            }
            try {
                data = buffer.toString().getBytes(encode);
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
            return new IvParameterSpec(data);
        }
    
        /**
         * 填充方式为Pkcs5Padding的加密函数
         * 填充方式为Pkcs5Padding时,最后一个块需要填充χ个字节,填充的值就是χ,也就是填充内容由JDK确定
         *
         * @param srcContent:  明文
         * @param password:   加密密钥(不足128bits时,填"0"补足)
         * @param iv:           初始向量(不足128bits时,填"0"补足)
         *
         * @return 密文(16进制表示)
         *
         */
        public static String encrypt(String srcContent, String password, String iv) {
            SecretKeySpec key = get128BitsKey(password);
            IvParameterSpec ivParameterSpec = get128BitsIV(iv);
            try {
                Cipher cipher = Cipher.getInstance(mode);
                cipher.init(Cipher.ENCRYPT_MODE, key, ivParameterSpec);
                byte[] byteContent = srcContent.getBytes(encode);
                byte[] encryptedContent = cipher.doFinal(byteContent);
                String result = HexUtil.byte2HexStr(encryptedContent);
                return result;
            } catch (Exception e) {
                e.printStackTrace();
            }
            return null;
        }
    
        /**
         * 填充方式为Pkcs5Padding的解密函数
         * 填充方式为Pkcs5Padding时,最后一个块需要填充χ个字节,填充的值就是χ,也就是填充内容由JDK确定
         *
         * @param encryptedContent:  密文
         * @param password:          加密密钥(不足128bits时,填"0"补足)
         * @param iv:                 初始向量(不足128bits时,填"0"补足)
         *
         * @return 密文(16进制表示)
         *
         */
        public static String decrypt(String encryptedContent, String password, String iv) {
            SecretKeySpec key = get128BitsKey(password);
            IvParameterSpec ivParameterSpec = get128BitsIV(iv);
            try {
                byte[] content = HexUtil.hexStr2Byte(encryptedContent);
                Cipher cipher = Cipher.getInstance(mode);
                cipher.init(Cipher.DECRYPT_MODE, key, ivParameterSpec);
                byte[] decryptedContent = cipher.doFinal(content);
                String result = new String(decryptedContent);
                return result;
            } catch (Exception e) {
                e.printStackTrace();
            }
            return null;
        }
    }

     

        WTUtil的创建接口“createJWT()”允许调用者指定那些JWT的声明需要加密,那些声明保持为明文的状态。例如JWT的过期时间“exp”,客户端可能需要这个值进行Token有效性判断,所以该claim就需要保持为明文。在调用者解析JWT的时候,JWTUtil的解析接口会将公开明文的publicClaims和解密后的privateClaims放到一个Map中,所以publicClaimsprivateClaims Mapkey值不要有重名的现象。相信实际的应用场景也不需要重名的Claims

     

        将以下3claims生成JWT

    {
      "groupId": "6fd5a193016d",
      "userName": "test123",
      "exp": 1528771799
    }

      未使用AES加密的JWT字符串:

     

    eyJhbGciOiJIUzI1NiJ9.eyJncm91cElkIjoiNmZkNWExOTMwMTZkIiwidXNlck5hbWUiOiJ0ZXN0MTIzIiwiZXhwIjoxNTI4NzcxNzk5fQ.vJQ4CeYKx3n6B709w35Xdv4fVB2YTr-tsAWr3tCe6A8

     

      使用https://jwt.io/#debugger解析后的原文:

      将groupId”和“userName”作为敏感信息使用AES加密后的JWT字符串:

    eyJhbGciOiJIUzI1NiJ9.eyJwcml2YXRlQ2xhaW1zIjoiMUYyOTMxNjM2NzlBMDM2NDI5RkE3NzMwRTc2OUQyQUY0NjdEMkM3M0Y1NDQxNEExMTVCQUI4MzdCQTEwODQ2NjU0QjA2MTE0OTEzQkJGOUNDMkRCQjdFQzM0RTc2NjIwIiwiZXhwIjoxNTI4Nzc5NjAzfQ.5xb_uxBHMAPvShsOC-pQIS746OjW5XMjj5tAcxwFCq8

      使用https://jwt.io/#debugger解析后的JWT原文:

     

      使用JWTUtil.parseJWT()”接口解析后的JWT原文Map对象:

    {groupId=6fd5a193016d, userName=test123, exp=1528771799}

      可以看到AES加密后,“groupId”和“userName”变成了JWT中“privateClaims”对应的密文。这样敏感信息就不怕泄露了。这个方法的缺点主要就是JWT字符串的长度从151增加到了243。如果JWT长度增加的太多,JJWT的接口还可以使用压缩算法对JWT字符串进行压缩。

  • 相关阅读:
    十五、MySQL DELETE 语句
    十三、MySQL WHERE 子句
    十四、MySQL UPDATE 查询
    十一、MySQL 插入数据
    十二、MySQL 查询数据
    十、MySQL 删除数据表
    九、MySQL 创建数据表
    八、MySQL 数据类型
    七、MySQL 选择数据库
    六、MySQL 删除数据库
  • 原文地址:https://www.cnblogs.com/standup/p/9188432.html
Copyright © 2011-2022 走看看