Python+Flask构建钉钉消息网关:HTTP推送接收实战

一、场景需求

当企业应用需要接收钉钉平台的事件推送(如审批通知、考勤打卡等),必须实现‌加密通信‌和‌签名验证‌双重安全机制。本文将通过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)

 

四、常见问题排查

  1. 签名错误
    检查token是否与钉钉后台一致,注意URL参数顺序为nonce,timestamp,token

  2. 解密失败
    确认encodingAesKey完整复制,包含末尾的=补位

  3. 超时问题
    使用time.time()确保时间戳为当前秒级时间

五、部署建议

if __name__ == '__main__':
    # 生产环境应使用Nginx+uWSGI
    app.run(host='0.0.0.0', port=8000, 
           ssl_context=('cert.pem', 'key.pem'))  # 启用HTTPS

 

技术要点总结‌:

  1. 钉钉使用‌双向加密‌机制,既验证请求来源又保护通信内容
  2. PKCS7填充确保数据长度符合AES分组要求
  3. 时间戳+随机数组合防止重放攻击

六、完整代码

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&timestamp=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)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值