一、场景需求
当企业应用需要接收钉钉平台的事件推送(如审批通知、考勤打卡等),必须实现加密通信和签名验证双重安全机制。本文将通过Flask框架演示完整实现方案。
二、核心安全机制
安全层 | 实现原理 | 对应代码方法 |
---|---|---|
AES加密 | 使用CBC模式+PKCS7填充 | encrypt() /getDecryptMsg() |
签名验证 | SHA1算法+参数排序 | generateSignature() |
随机数防护 | 16位随机字符串 | generateRandomKey() |
三、代码精讲
1. 加密解密类(DingCallback)
class DingCallback:
def __init__(self, token, encodingAesKey, key):
# 关键参数说明:
# token -> 钉钉后台配置的签名令牌
# encodingAesKey -> 43位加密密钥(需补'='后base64解码)
# key -> 企业corpId或应用appKey
self.aesKey = base64.b64decode(encodingAesKey + '=')
2. 解密流程详解
def getDecryptMsg(self, msg_signature, timestamp, nonce, content):
# 步骤1:验证签名防止篡改
sign = self.generateSignature(nonce, timestamp, self.token, content)
if msg_signature != sign:
raise ValueError('签名验证失败')
# 步骤2:AES-CBC模式解密
iv = self.aesKey[:16]
cipher = AES.new(self.aesKey, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(base64.b64decode(content))
# 步骤3:去除PKCS7填充
pad = decrypted[-1]
clean_data = decrypted[:-pad]
# 步骤4:验证企业标识
corp_id = clean_data[20+l:].decode()
if corp_id != self.key:
raise ValueError('企业身份校验失败')
3. 加密响应规范
@app.route('/dingding', methods=['POST'])
def ding_callback():
# 必须1500ms内返回加密响应
response = dingCrypto.getEncryptedMap()
# 返回结构示例:
# {
# "msg_signature": "5c45b5...",
# "encrypt": "abc123...",
# "timeStamp": "1234567890",
# "nonce": "xyz987"
# }
return jsonify(response)
四、常见问题排查
-
签名错误
检查token是否与钉钉后台一致,注意URL参数顺序为nonce,timestamp,token
-
解密失败
确认encodingAesKey完整复制,包含末尾的=
补位 -
超时问题
使用time.time()
确保时间戳为当前秒级时间
五、部署建议
if __name__ == '__main__':
# 生产环境应使用Nginx+uWSGI
app.run(host='0.0.0.0', port=8000,
ssl_context=('cert.pem', 'key.pem')) # 启用HTTPS
技术要点总结:
- 钉钉使用双向加密机制,既验证请求来源又保护通信内容
- PKCS7填充确保数据长度符合AES分组要求
- 时间戳+随机数组合防止重放攻击
六、完整代码
from flask import Flask, request, jsonify
import time
import io,base64, binascii, hashlib, string, struct
from random import choice
from Crypto.Cipher import AES
class DingCallback:
def __init__(self, token, encodingAesKey, key):
self.encodingAesKey = encodingAesKey
self.key = key
self.token = token
self.aesKey = base64.b64decode(self.encodingAesKey + '=')
## 生成回调处理完成后的success加密数据
def getEncryptedMap(self, content='success'):
encryptContent = self.encrypt(content)
timeStamp = str(int(time.time()))
nonce = self.generateRandomKey(16)
sign = self.generateSignature(nonce, timeStamp, self.token,encryptContent)
return {'msg_signature':sign,'encrypt':encryptContent,'timeStamp':timeStamp,'nonce':nonce}
##解密钉钉发送的数据
def getDecryptMsg(self, msg_signature, timeStamp,nonce, content):
sign = self.generateSignature(nonce, timeStamp, self.token,content)
if msg_signature != sign:
raise ValueError('signature check error')
content = base64.decodebytes(content.encode('UTF-8')) ##钉钉返回的消息体
iv = self.aesKey[:16] ##初始向量
aesDecode = AES.new(self.aesKey, AES.MODE_CBC, iv)
decodeRes = aesDecode.decrypt(content)
pad = int(decodeRes[-1])
if pad > 32:
raise ValueError('Input is not padded or padding is corrupt')
decodeRes = decodeRes[:-pad]
l = struct.unpack('!i', decodeRes[16:20])[0]
nl = len(decodeRes)
if decodeRes[(20+l):].decode() != self.key:
raise ValueError('corpId 校验错误')
return decodeRes[20:(20+l)].decode()
##加密
def encrypt(self, content):
msg_len = self.length(content)
content = ''.join([self.generateRandomKey(16) , msg_len.decode() , content , self.key])
contentEncode = self.pks7encode(content)
iv = self.aesKey[:16]
aesEncode = AES.new(self.aesKey, AES.MODE_CBC, iv)
aesEncrypt = aesEncode.encrypt(contentEncode.encode('UTF-8'))
return base64.encodebytes(aesEncrypt).decode('UTF-8')
### 生成回调返回使用的签名值
def generateSignature(self, nonce, timestamp, token, msg_encrypt):
v = msg_encrypt
signList = ''.join(sorted([nonce, timestamp, token, v]))
return hashlib.sha1(signList.encode()).hexdigest()
def length(self, content):
l = len(content)
return struct.pack('>l', l)
def pks7encode(self, content):
l = len(content)
output = io.StringIO()
val = 32 - (l % 32)
for _ in range(val):
output.write('%02x' % val)
return content + binascii.unhexlify(output.getvalue()).decode()
def pks7decode(self, content):
nl = len(content)
val = int(binascii.hexlify(content[-1]), 16)
if val > 32:
raise ValueError('Input is not padded or padding is corrupt')
l = nl - val
return content[:l]
def generateRandomKey(self, size,
chars=string.ascii_letters + string.ascii_lowercase + string.ascii_uppercase + string.digits):
return ''.join(choice(chars) for i in range(size))
app = Flask(__name__)
@app.route('/dingding', methods=['POST'])
def ding_callback():
"""
钉钉会主动向配置的HTTP地址发送POST请求的格式:
http://你注册的HTTP地址?signature=111108bb8e6dbc2xxxx×tamp=1783610513&nonce=380320111
包含的JSON数据:
{"encrypt":"1ojQf0NSvw2WPvW7LijxS8UvISr8pdDP+rXpPbcLGOmIBNbWetRg7IP0vdhVgkVwSoZBJeQwY2zhROsJq/HJ+q6tp1qhl"}
"""
signature = request.args.get('signature')
timestamp = request.args.get('timestamp')
nonce = request.args.get('nonce')
encrypt = request.get_json()['encrypt']
"""
token 钉钉开放平台上,开发者设置的 签名 token
aes_key 钉钉开放台上,开发者设置的 加密 aes_key
appkey 企业自建应用-事件订阅, 使用appKey
"""
token = ''
aes_key = ''
appkey = 'ding*******'
dingCrypto = DingCallback(token, aes_key, appkey)
## 解密钉钉发送过来的json,首次连接验证显示{"EventType":"check_url"}
decrypt = dingCrypto.getDecryptMsg(signature,timestamp,nonce,encrypt)
print(f'解密:{decrypt}')
##官方原话:当你收到开放平台的POST验证请求时,你需要做解密处理,并在1500ms内返回包含success的加密字符串(JSON格式),data就是要返回的数据
data = dingCrypto.getEncryptedMap()
return jsonify(data)
if __name__ == '__main__':
# 生产环境应使用Nginx+uWSGI
app.run(host='0.0.0.0', port=8000)