【效能提升】token不一样的玩法

223 阅读9分钟

image.png

背景

作为一个纯粹的Java Boy,一生致力于使用springmaven来开发JAVA WEB项目,我们入门业务的第一课都是登录注册,完成注册后,我们去完成登录时,往往会生成一个令牌token来标识这个用户的身份

ACL

作为一个专业的CURD仔,为了开发效率我们都会奉行拿来主义,在实现登录验证时,我们一般采用采用Spring Security来实现,采用Spring Security的好处有他提供了token生成和验证的方法,为了便利我们一般会使用JWT去生成token

JWT

JWT作为一种开放标准(RFC7519)有以下特点参考来自

  1. 无需服务器存储状态:传统会话认证机制需要服务器在会话中存储用户的状态信息,使用 JWT,服务器无需存储任何会话状态信息,所有的认证和授权信息都包含在 JWT 中。
  2. 跨域支持:由于 JWT 包含了完整的认证和授权信息,可以轻松地在多个域之间进行传递和使用实现跨域授权。
  3. 适应微服务架构:在微服务架构中,很多服务是独立部署并且可以横向扩展的,这就需要保证认证和授权的无状态性。使用 JWT 可以满足这种需求,每次请求携带 JWT 即可实现认证和授权。
  4. 自包含:JWT 包含了认证和授权信息,以及其他自定义的声明,这些信息都被编码在 JWT 中,在服务端解码后使用。JWT 的自包含性减少了对服务端资源的依赖,并提供了统一的安全机制。
  5. 扩展性:JWT 可以被扩展和定制,可以按照需求添加自定义的声明和数据,灵活性更高。

But

但是因为他的自包性和跨域支持,让我们可以在拿到token后可以在别的地方通过其标准的协议来进行解密,从而拿到body体和签名算法,例如

image.png

这样来看,我们在body就只能存放可以明着看的数据,比如id隐匿部分数据的用户账号用户昵称等数据,但是在一些我们需要存储or使用他们用户其他的用户数据时,就只能通过用户id再去进行一次查询,这操作显然会消耗更多的rpc资源尤其是针对C端应用时

曲线救国

我们知道,token其实就是对一串字符串进行加密后形成一串字符串,又为了提升利用效率我们都尽可能的把有用的信息塞进token中,但是又为了安全我们不能使用jwt的开放协议,我们可以设计一套自己的token加密逻辑

image.png

生成token

我们常用的用户信息有用户的账号id昵称账号用户组id权限列表签发时间有效时间签发来源等等,我们可以来定义一下用户信息的数据结构

public class UserInfo{
    //账号id  8bit
    private Long userId;
    //昵称  dynamic
    private String nickName;
    //账号  8bit
    private Long account;
    //用户组id  8bit
    private Long userGroupId;
    //权限列表 8bit * N
    private Long[] aclList;
    //签发时间  8bit
    private Long generateTime;
    //有效时间  4bit
    private Integer validTime;
    //有效时间计算单位 2bit
    private Short validUnit;
    //签发来源  2bit
    private Short source;
}

有了这个用户信息的容器,我们可以来将他转换成byte数组,从而通过加密算法对其进行加密,通过包装类型的,我们可以知道Long类型占用64个比特,也就是8字节,而Integer类型占用32比特,也就是4字节,Short类型占用16比特,也就是2字节。

image.png

image.png

image.png

我们知道的,加密算法对数据进行加密前,需要对数据位数进行对齐,我们以RSA算法为例,在使用1024位的密钥,我们可以一次加密一块117字节的数据,而2048位密钥能加密245字节的数据,4096位密钥则是512字节。

根据加密算法规则,我们可以根据消息体长度去选择对应的算法,在本文中,我们设定的用户信息基础信息占用了8+动态长度+8+8+8*N++4+2+2=32+动态长度+8*N,假设我们的昵称长度限制为15个中文+长度位置一个Short也就是30+2=32字节,加上权限列表,也就是我们通过117字节进行计算就够,如若还需使用其他数据可以选择其他的算法,并对用户信息进行调整,我们这里设定权限容量N为6,其中0为无效,为此,我们改造一下UserInfo类

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
class UserInfo{

    //账号id  8bit
    private Long userId;
    //昵称  dynamic
    private String nickName;
    //账号  8bit
    private Long account;
    //用户组id  8bit
    private Long userGroupId;
    //权限列表 8bit * N
    private Long[] aclList;
    //签发时间  8bit
    private Long generateTime;
    //有效时间  4bit
    private Integer validTime;
    //有效时间计算单位 2bit
    private Short validUnit;
    //签发来源  2bit
    private Short source;
    /**
     * 把数据解析成bytes
     */
    private byte[] bean2byts(){
        ByteBuffer byteBuffer = ByteBuffer.allocate(128);
        byteBuffer.putLong(userId);
        //限制长度不超过30字节
        short length=Integer.valueOf(nickName.length()).shortValue();
        byteBuffer.putShort(length);
        for(int strIdx=0;strIdx<length;strIdx++){
            byteBuffer.putChar(nickName.charAt(strIdx));
        }
        byteBuffer.putLong(account);
        byteBuffer.putLong(userGroupId);
        for(int aclIdx=0;aclIdx<8;aclIdx++){
            if(aclIdx>=aclList.length){
                byteBuffer.putLong(0L);
            }else {
                byteBuffer.putLong(aclList[aclIdx]);
            }
        }
        byteBuffer.putLong(generateTime);
        byteBuffer.putInt(validTime);
        byteBuffer.putShort(validUnit);
        byteBuffer.putShort(source);
        return byteBuffer.array();
    }
    /**
     * 进行加密
     */
    public String encode(PublicKey publicKey) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException, ShortBufferException {
        byte[] bodys=bean2byts();
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.ENCRYPT_MODE,publicKey);
        byte[] res=cipher.doFinal(bodys,0, 117);
        return Base64.getEncoder().encodeToString(res);
    }
}

在这我们实现了生成token,那如何进行解析呢,我们可以把token进行解base64变成bytes后再通过RSA解密后填充回UserInfo

private UserInfo bytes2Bean(byte[] body){
    ByteBuffer byteBuffer = ByteBuffer.wrap(body);
    userId=byteBuffer.getLong();
    short length=byteBuffer.getShort();
    StringBuilder nickNameStrBuilder=new StringBuilder();
    for(int strIdx=0;strIdx<length;strIdx++){
        nickNameStrBuilder.append(byteBuffer.getChar());
    }
    nickName=nickNameStrBuilder.toString();
    account=byteBuffer.getLong();
    userGroupId=byteBuffer.getLong();
    for(int aclIdx=0;aclIdx<6;aclIdx++){
        aclList[aclIdx]=byteBuffer.getLong();
    }
    generateTime=byteBuffer.getLong();
    validTime=byteBuffer.getInt();
    validUnit=byteBuffer.getShort();
    source=byteBuffer.getShort();
    return this;
}

public UserInfo decode(String token, PrivateKey privateKey) throws InvalidKeyException, NoSuchPaddingException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException {
    byte[] body=Base64.getDecoder().decode(token);
    Cipher cipher = Cipher.getInstance("RSA");
    cipher.init(Cipher.DECRYPT_MODE,privateKey);
    byte[] afterRsaDecode=cipher.doFinal(body);
    return bytes2Bean(afterRsaDecode);
}

加盐

image.png

是不是以为这时候就可以用了?确实是可以用了,但是这是直接对byte进行加密,加密后的bytes直接转base64就返回了,这可能会被别人hook出结果,这时候我们还需要对其加盐进行混淆,让base64无法直接进行解析,我们可以进行以下操作

//获取随机字符串
private String getRandomString(int length){
    SecureRandom random = new SecureRandom();
    StringBuilder sb = new StringBuilder(length);
    for (int i = 0; i < length; i++) {
        int randomInt = random.nextInt(62);
        char randomChar;
        if (randomInt < 10) {
            randomChar = (char) ('0' + randomInt);
        } else if (randomInt < 36) {
            randomChar = (char) ('A' + randomInt - 10);
        } else {
            randomChar = (char) ('a' + randomInt - 36);
        }
        sb.append(randomChar);
    }
    return sb.toString();
}

//获取转移后的字符串
public String getToken(String realToken){
    StringBuilder tokenBuilder=new StringBuilder();
    tokenBuilder.append("!1hassan");//可以自定义字符集
    //随机计算出插入颜的位置,采用2位16进制,所以max=FF,但是FF<字符串长度
    SecureRandom random = new SecureRandom();
    int randomIndex=random.nextInt(Math.min(realToken.length(), 0xFF));
    tokenBuilder.append(Integer.toHexString(randomIndex));
    //计算随机长度,采用1位16进制,0~F
    int randomLength=random.nextInt(16);
    tokenBuilder.append(Integer.toHexString(randomLength));
    String randomSalt=getRandomString(randomLength);
    if(randomIndex>0){
        tokenBuilder.append(realToken.substring(0,randomIndex));
        tokenBuilder.append(randomSalt);
        tokenBuilder.append(realToken.substring(randomIndex));
    }else {
        tokenBuilder.append(randomSalt);
        tokenBuilder.append(realToken);

    }
    return tokenBuilder.toString();
}

然后在解析之前去除这些数据

public String getRealToken(String token){
    token=token.substring("!1hassan".length());
    int randomIndex=Integer.parseInt(token.substring(0,2),16);
    int randomLength=Integer.parseInt(token.substring(2,3),16);
    token=token.substring(3);
    String realToken="";
    if(randomIndex==0){
        realToken=token.substring(randomLength);
    }else {
        realToken=token.substring(0,randomIndex)+token.substring(randomIndex+randomLength);
    }
    return realToken;
}

这时候,我们使用加盐后的base64字符串去解密时,就会出现以下异常

image.png

最后附上完整的UserInfo对象代码,不过需要你注意的是,这里采用的是1024位的rsa密钥

image.png

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
class UserInfo{

    //账号id  8bit
    private Long userId;
    //昵称  dynamic
    private String nickName;
    //账号  8bit
    private Long account;
    //用户组id  8bit
    private Long userGroupId;
    //权限列表 8bit * N
    private Long[] aclList;
    //签发时间  8bit
    private Long generateTime;
    //有效时间  4bit
    private Integer validTime;
    //有效时间计算单位 2bit  0 分钟 1小时  2 天数  3 周 4 天
    private Short validUnit;
    //签发来源  2bit
    private Short source;
    
    //javaBean转bytes
    private byte[] bean2byts(){
        ByteBuffer byteBuffer = ByteBuffer.allocate(128);
        byteBuffer.putLong(userId);
        //限制长度不超过30字节
        short length=Integer.valueOf(nickName.length()).shortValue();
        byteBuffer.putShort(length);
        for(int strIdx=0;strIdx<length;strIdx++){
            byteBuffer.putChar(nickName.charAt(strIdx));
        }
        byteBuffer.putLong(account);
        byteBuffer.putLong(userGroupId);
        for(int aclIdx=0;aclIdx<6;aclIdx++){
            if(aclIdx>=aclList.length){
                byteBuffer.putLong(0L);
            }else {
                byteBuffer.putLong(aclList[aclIdx]);
            }
        }
        byteBuffer.putLong(generateTime);
        byteBuffer.putInt(validTime);
        byteBuffer.putShort(validUnit);
        byteBuffer.putShort(source);
        return byteBuffer.array();
    }


    //进行加密
    public String encode(PublicKey publicKey) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException, ShortBufferException {
        byte[] bodys= bean2byts();
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.ENCRYPT_MODE,publicKey);
        byte[] res=cipher.doFinal(bodys,0, 117);
        return Base64.getEncoder().encodeToString(res);
    }

    //获取随机字符串
    private String getRandomString(int length){
        SecureRandom random = new SecureRandom();
        StringBuilder sb = new StringBuilder(length);
        for (int i = 0; i < length; i++) {
            int randomInt = random.nextInt(62);
            char randomChar;
            if (randomInt < 10) {
                randomChar = (char) ('0' + randomInt);
            } else if (randomInt < 36) {
                randomChar = (char) ('A' + randomInt - 10);
            } else {
                randomChar = (char) ('a' + randomInt - 36);
            }
            sb.append(randomChar);
        }
        return sb.toString();
    }

    //获取转移后的字符串
    public String getToken(String realToken){
        StringBuilder tokenBuilder=new StringBuilder();
        tokenBuilder.append("!1hassan");//可以自定义字符集
        //随机计算出插入颜的位置,采用2位16进制,所以max=FF,但是FF<字符串长度
        SecureRandom random = new SecureRandom();
        int randomIndex=random.nextInt(Math.min(realToken.length(), 0xFF));
        tokenBuilder.append(Integer.toHexString(randomIndex));
        //计算随机长度,采用1位16进制,0~F
        int randomLength=random.nextInt(16);
        tokenBuilder.append(Integer.toHexString(randomLength));
        String randomSalt=getRandomString(randomLength);
        if(randomIndex>0){
            tokenBuilder.append(realToken.substring(0,randomIndex));
            tokenBuilder.append(randomSalt);
            tokenBuilder.append(realToken.substring(randomIndex));
        }else {
            tokenBuilder.append(randomSalt);
            tokenBuilder.append(realToken);

        }
        return tokenBuilder.toString();
    }

    //bytes转成javaBean
    private UserInfo bytes2Bean(byte[] body){
        ByteBuffer byteBuffer = ByteBuffer.wrap(body);
        userId=byteBuffer.getLong();
        short length=byteBuffer.getShort();
        StringBuilder nickNameStrBuilder=new StringBuilder();
        for(int strIdx=0;strIdx<length;strIdx++){
            nickNameStrBuilder.append(byteBuffer.getChar());
        }
        nickName=nickNameStrBuilder.toString();
        account=byteBuffer.getLong();
        userGroupId=byteBuffer.getLong();
        for(int aclIdx=0;aclIdx<6;aclIdx++){
            aclList[aclIdx]=byteBuffer.getLong();
        }
        generateTime=byteBuffer.getLong();
        validTime=byteBuffer.getInt();
        validUnit=byteBuffer.getShort();
        source=byteBuffer.getShort();
        return this;
    }

    //RSA解密
    public UserInfo decode(String token, PrivateKey privateKey) throws InvalidKeyException, NoSuchPaddingException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException {
        byte[] body=Base64.getDecoder().decode(token);
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.DECRYPT_MODE,privateKey);
        byte[] afterRsaDecode=cipher.doFinal(body);
        return bytes2Bean(afterRsaDecode);
    }

    //获取真实的token
    public String getRealToken(String token){
        token=token.substring("!1hassan".length());
        int randomIndex=Integer.parseInt(token.substring(0,2),16);
        int randomLength=Integer.parseInt(token.substring(2,3),16);
        token=token.substring(3);
        String realToken="";
        if(randomIndex==0){
            realToken=token.substring(randomLength);
        }else {
            realToken=token.substring(0,randomIndex)+token.substring(randomIndex+randomLength);
        }
        return realToken;
    }
}

测试main方法

public static void main(String[] args) {
   try {
       String publicKeyStr = "公钥";
       String privateKeyStr = "私钥";

       Long[] aclList=new Long[]{1L,2L,3L,4L,5L,6L};
       UserInfo userInfo=UserInfo.builder().userId(1L).nickName("昵称").account(180200220L).userGroupId(1L).aclList(aclList)
               .generateTime(System.currentTimeMillis()/1000L).validTime(100).validUnit((short) 2).source((short) 1)
               .build();
       byte[] decodedKey = org.apache.commons.codec.binary.Base64.decodeBase64(publicKeyStr.getBytes());
       X509EncodedKeySpec keySpec = new X509EncodedKeySpec(decodedKey);
       KeyFactory keyFactory = KeyFactory.getInstance("RSA");
       PublicKey publicKey= keyFactory.generatePublic(keySpec);
       PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(org.apache.commons.codec.binary.Base64.decodeBase64( privateKeyStr.getBytes()));
       PrivateKey privateKey = keyFactory.generatePrivate(pkcs8EncodedKeySpec);

       String token=userInfo.encode(publicKey);
       System.out.println(token);
       String returnToken = userInfo.getToken(token);
       System.out.println(returnToken);

       String realToken=userInfo.getRealToken(returnToken);
       System.out.println(realToken);

       UserInfo userInfoDecode=userInfo.decode(realToken,privateKey);
       System.out.println(JSONObject.toJSONString(userInfoDecode));

   }catch (Exception e){
       //TODO must deal witch exception
       e.printStackTrace();
   }
}