开通钉钉云需要付费购买阿里云cdn(约2w/年), 那想要免费验证业务逻辑,在不使用钉钉云的情况下,只能使用https回调的方式来调用钉钉api
内网穿透
如果服务器搭建在内网, 首先需要进行内网穿透, 将内网服务器反向绑定一个域名, 这样钉钉服务器就可以找到内网的服务器发送验证消息了
内网穿透原理与部署
安装好pierced后, 启动cmd黑窗口,注意不能用win10的powershell, 键入ding -config=./ding.cfg -subdomain=abcd1447 3000
, 将本地127.0.0.1:3000
映射成 http://abcd1447.vaiwan.com
网址, 这样钉钉就可以通过http://abcd1447.vaiwan.com
来找到我们内网的服务器了
签名验证
首先验证钉钉发来的密文签名, 验证签名的作用是为了确保来自可靠的来源并在传输过程中没有被修改.在query中获取 signature,timestamp,nonce等明文参数, 在body中获取传输密文
```
//此处为钉钉服务器发来的请求参数,由钉钉服务器产生,在query中
const {
signature,
timestamp,
nonce
} = req.query
//此处为钉钉服务器发来的密文,在body中
const {
encrypt
} = req.body
```
然后获取在开发者平台设置的token
const token = config.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] ), 下面是构造过程
- 生成回复给钉钉的明文和长度
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);
});
- 将明文长度转化成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
- 在本地生成签名,发送给钉钉会进行校验
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);
})
网友评论