钉钉小程序回调推送认证(node版本)

开通钉钉云需要付费购买阿里云cdn(约2w/年), 那想要免费验证业务逻辑,在不使用钉钉云的情况下,只能使用https回调的方式来调用钉钉api

内网穿透

如果服务器搭建在内网, 首先需要进行内网穿透, 将内网服务器反向绑定一个域名, 这样钉钉服务器就可以找到内网的服务器发送验证消息了
拓扑图如下


钉钉开发环境.png

本地服务器拉取钉钉服务,再加上本地服务一起打包上传到钉钉第三方企业程序,然后用户就能用手机实现刷门禁的时候同步上班打卡了,当然这是开发时节约成本的方法,实际的生产环境还是要上钉钉云


钉钉生产.png

内网穿透原理与部署
安装好pierced后, 启动cmd黑窗口,注意不能用win10的powershell, 键入ding -config=./ding.cfg -subdomain=abcd1447 3000, 将本地127.0.0.1:3000 映射成 http://abcd1447.vaiwan.com 网址, 这样钉钉就可以通过http://abcd1447.vaiwan.com来找到我们内网的服务器了

内网穿透成功.png

签名验证

首先验证钉钉发来的密文签名, 验证签名的作用是为了确保来自可靠的来源并在传输过程中没有被修改.在query中获取 signature,timestamp,nonce等明文参数, 在body中获取传输密文

```
   //此处为钉钉服务器发来的请求参数,由钉钉服务器产生,在query中
    const {
        signature,
        timestamp,
        nonce
    } = req.query

   //此处为钉钉服务器发来的密文,在body中
   const {
        encrypt
    } = req.body
```

然后获取在开发者平台设置的token
const token = config.token

在钉钉平台上设置的Token

然后将参数按照字母字典排序,然后从小到大拼接成一个字符串,再使用node中的crypto-js库,用来计算消息的摘要

```
    const sortList = [timestamp, nonce, encrypt, token];
    sortList.sort();
    let msg_str = '';
    for (let text of sortList) {
        msg_str += text;
    }
    const msg_signature = CryptoJS.SHA1(msg_str).toString()
 ```

最后再将msg_signature与signature进行比较,signature为钉钉服务器算出, msg_signature为本地验证生成, 若为打印true,证明在传输中没有被篡改,若为false,则应该重新发起请求,重传该数据
console.log('签名有效性', msg_signature === signature);

解密钉钉的测试密文

钉钉的测试密文使用AES-CRC模式加密, 在开发者平台设置的数据加密密钥,钉钉规定长度固定为43个字符,这是base64编码,base64编码必须是4的倍数才可以解密, 需要在末尾加上一个'='补成44位, 即可解密base64 ,解密需要使用node的atob库,网上使用buffer.from并不行,因为buffer只能转可见字符,而钉钉的密钥编码中采用了很多不可见字符,使用buffer会造成错误
const ddKey = config.ddKey + '=' , const AESKey = atob(ddKey)
得出32位密钥之后,要根据AESKey算出初始偏移向量iv,该向量只对CBC数据块的第一个块加密有用处,AES的CBC模式加密是一种类似于区块链的加密方法,每个块被前一个块影响, 而iv根据AESKey算出,该向量只对CBC数据块的第一个块加密有用处

      let IV = ''
      for (let i = 0; i < 16; i++) {
          IV += AESKey[i]
      }

之后进行解密,解密算法,注意要使用Latin1解析,这是一种单字节解析,适合解析密码,而utf8通常是3字节解析,适合解析多语言的数据

     function decrypt(data) {
        const CBCOptions = {
            iv: CryptoJS.enc.Latin1.parse(IV),
            mode: CryptoJS.mode.CBC,
            padding: CryptoJS.pad.Pkcs7
        }
        const key = CryptoJS.enc.Latin1.parse(AESKey)
        const decrypt = CryptoJS.AES.decrypt(
            data,
            key,
            CBCOptions
        );
        return CryptoJS.enc.Latin1.stringify(decrypt).toString();
    }

解密出钉钉服务器发送来的消息之后, 去除头部20字节,尾部字符串,得到钉钉返回的消息体,ddMsg即为解密出来之后的明文

   const ddMsgStr = decrypt(encrypt)
   let ddMsg = JSON.parse(ddMsgStr.substring(20, ddMsgStr.lastIndexOf('}') + 1))
   console.log('ddMsg', ddMsg);

回复钉钉密文

回复钉钉的格式为:msg_encrypt = Base64_Encode( AES_Encrypt[random(16B) + msg_len(4B) + msg + $key] ), 下面是构造过程

  1. 生成回复给钉钉的明文和长度
    const msg = 'success'
    const msg_len = msg.length

2.生成16位随机数

const x16 = new Array(16).fill('x')
const randomString = x16.join().replace(/,/g, '').replace(/[x]/g, function () {
    let r = Math.floor(Math.random() * 16)
    return r.toString(16);
});
  1. 将明文长度转化成32位二进制数字,注意不能写成'0007',因为这样会"0007"被转化成ascll码,要使用fromCharCode来转码
    const lengthString = String.fromCharCode(0) + String.fromCharCode(0) + String.fromCharCode(0) + String.fromCharCode(msg_len)

4.生成suitkey
const $key = ddMsg.TestSuiteKey || ddMsg.SuiteKey

5.构造的回复明文
const preMsg = randomString + lengthString + msg + $key

6.加密函数加密数据

  function encryptData(data) {
    var CBCOptions = {
        iv: CryptoJS.enc.Latin1.parse(IV),
        mode: CryptoJS.mode.CBC,
        padding: CryptoJS.pad.Pkcs7
    }
    var secretData = CryptoJS.enc.Latin1.parse(data);
    const key = CryptoJS.enc.Latin1.parse(AESKey)
    var encrypted = CryptoJS.AES.encrypt(
        secretData,
        key,
        CBCOptions
    );
    //这里默认返回base64数据
    return encrypted.toString();
}

const base64encryptedData = encryptData(preMsg)

7.生成时间戳,后端时间戳是10位,精确到s, 前端为13位,精确到ms
const localTimestamp = "" + parseInt(new Date() / 1000)

8.生成随机数,用于加盐,每次签名会因此而不同
const localNonce = Math.floor(Math.random() * 100000 + 100000) + ''

9.token要与在开发者平台设置的一致
const token = config.token

  1. 在本地生成签名,发送给钉钉会进行校验
let local_msg_str = '';
for (let text of sortLists) {
      local_msg_str += text;
}
 const local_msg_signatures CryptoJS.SHA1(local_msg_str).toString()

11.回复给钉钉的数据

    const obj = {
        timeStamp: localTimestamp,
        msg_signature: local_msg_signatures,
        encrypt: base64encryptedData,
        nonce: localNonce,
    }
    res.send(obj);

完整函数

app.use('/ddCallback', async function (req, res) {
    //此处为钉钉服务器发来的请求参数,由钉钉服务器产生
    const {
        signature,
        timestamp,
        nonce
    } = req.query

    //此处为钉钉服务器发来的密文
    const {
        encrypt
    } = req.body

    //在开发者平台设置的token
    const token = 'dingURL'

    //将参数按照字母字典排序,然后从小到大拼接成一个字符串。
    const sortList = [timestamp, nonce, encrypt, token];
    sortList.sort();
    let msg_str = '';
    for (let text of sortList) {
        msg_str += text;
    }
    //CryptoJS为node中的crypto-js库,用来计算消息的摘要
    const msg_signature = CryptoJS.SHA1(msg_str).toString()


    //signature为钉钉服务器算出, msg_signature为本地验证生成,
    // 若为打印true,证明在传输中没有被篡改
    console.log('签名有效性', msg_signature === signature);

    //在开发者平台设置的数据加密密钥,钉钉规定长度固定为43个字符,这是base64编码,
    //在末尾加上一个'='补成44位即可解密
    const ddKey = 'eh226ieev2hrxi7rwsa9gltxgx2u5e62xvn5r13f687='

    //解密需要使用node的atob库,网上使用buffer.from并不行,因为buffer只能转可见字符,
    //而钉钉的密钥编码中采用了很多不可见字符,使用buffer会造成很多错误
    const AESKey = atob(ddKey)

    // 根据AESKey算出初始偏移向量,该向量只对CBC数据块的第一个块加密有用处
    //AES的CBC模式加密是一种类似于区块链的加密方法,每个块被前一个块影响
    let IV = ''
    for (let i = 0; i < 16; i++) {
        IV += AESKey[i]
    }

    //解密算法,注意要使用Latin1解析,这是一种单字节解析,适合解析密码,
    //而utf8通常是3字节解析,适合解析多语言的数据
    function decrypt(data) {
        const CBCOptions = {
            iv: CryptoJS.enc.Latin1.parse(IV),
            mode: CryptoJS.mode.CBC,
            padding: CryptoJS.pad.Pkcs7
        }
        const key = CryptoJS.enc.Latin1.parse(AESKey)
        const decrypt = CryptoJS.AES.decrypt(
            data,
            key,
            CBCOptions
        );
        return CryptoJS.enc.Latin1.stringify(decrypt).toString();
    }
    //解密出钉钉服务器发送来的消息
    const ddMsgStr = decrypt(encrypt)
    //去除头部20字节,尾部字符串
    let ddMsg = JSON.parse(ddMsgStr.substring(20, ddMsgStr.lastIndexOf('}') + 1))
    //钉钉返回的消息体
    console.log('ddMsg', ddMsg);
    //全局存储
    SuiteTicket = ddMsg.SuiteTicket

    //回复给钉钉的明文和长度
    const msg = 'success'
    const msg_len = msg.length
    //16位随机数
    const x16 = new Array(16).fill('x')
    const randomString = x16.join().replace(/,/g, '').replace(/[x]/g, function () {
        let r = Math.floor(Math.random() * 16)
        return r.toString(16);
    });
    // 将明文长度转化成32位二进制数字,注意不能写成'0007',这样会被转化成ascll码
    const lengthString = String.fromCharCode(0) + String.fromCharCode(0) + String.fromCharCode(0) + String.fromCharCode(msg_len)

    const $key = ddMsg.TestSuiteKey || ddMsg.SuiteKey

    //构造的回复明文
    //回复钉钉的格式为:msg_encrypt = Base64_Encode( AES_Encrypt[random(16B) + msg_len(4B) + msg + $key] ), 下面是构造过程
    const preMsg = randomString + lengthString + msg + $key
    //加密函数
    function encryptData(data) {
        var CBCOptions = {
            iv: CryptoJS.enc.Latin1.parse(IV),
            mode: CryptoJS.mode.CBC,
            padding: CryptoJS.pad.Pkcs7
        }
        var secretData = CryptoJS.enc.Latin1.parse(data);
        const key = CryptoJS.enc.Latin1.parse(AESKey)
        var encrypted = CryptoJS.AES.encrypt(
            secretData,
            key,
            CBCOptions
        );
        //这里默认返回base64数据
        return encrypted.toString();
    }

    //加密数据
    const base64encryptedData = encryptData(preMsg)
    // 后端时间戳是10位,精确到s, 前端为13位,精确到ms
    const localTimestamp = "" + parseInt(new Date() / 1000)
    //每次生成随机数,用于加盐,每次签名会因此而不同
    const localNonce = Math.floor(Math.random() * 100000 + 100000) + ''
    // token要与在开发者平台设置的一致
    const sortLists = [localTimestamp, localNonce, base64encryptedData, token];
    sortLists.sort();
    //在本地生成签名,钉钉会进行校验
    let local_msg_str = '';
    for (let text of sortLists) {
        local_msg_str += text;
    }
    const local_msg_signatures = CryptoJS.SHA1(local_msg_str).toString()
    //回复给钉钉的数据
    const obj = {
        timeStamp: localTimestamp,
        msg_signature: local_msg_signatures,
        encrypt: base64encryptedData,
        nonce: localNonce,
    }
    res.send(obj);

})
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270