0.需求分析

需要实现钉钉企业通讯录同步至企业微信通讯录,这就需要用到钉钉的事件与回调

1.配置应用

登陆开放平台

https://open-dev.dingtalk.com/

去企业内部开发里面,先创建个应用,后面都借用这个应用来调接口

钉钉订阅的Stream模式 python 钉钉怎么订阅_python

创建完成应用后进入应用,找到下面红框内的数据,后面会用到

钉钉订阅的Stream模式 python 钉钉怎么订阅_json_02


进入应用中的事件与回调,自动生成aes_keytoken,然后保存就好这两个数据

钉钉订阅的Stream模式 python 钉钉怎么订阅_钉钉_03

2.服务开发

2.1请求验证

配置上面1.配置应用中的事件订阅下的请求网址如http://yourserver/api/callback时,需要把这个网址的接口开发好。钉钉会发送一个类似下面这样的请求:

一个POST类型的http请求,携带了部分url参数和json的加密参数

请求的路径参数包括signaturemsg_signaturetimestampnonce,请求体只有一个encryptjson

curl -X 'POST' \
  'http://yourserver:80/api/callback?signature=369beedea8d1c8d1ad18936e827d29d0c8415baf&msg_signature=369beedea8d1c8d1ad18936e827d29d0c8415baf×tamp=1660634610203&nonce=kBms4hUF' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "encrypt": "4Q4JHq88OCR3P+8v2mcFHLT6dmaaYAckaUBVk1spJnCx7u9raGZVAxVUIuQg3loL8LjIQj+5YC3+HJcehTsJXu1qMOv5TKdb4+koO55g8WCYZP/vebg2RZQC2gBlN2zv"
}'

钉钉服务器会向配置好的接口服务中发送请求,用来验证双方通信的真实性,接口服务端可通过解密encrypt中的数据来验证是否是来自钉钉服务器,而返回的加密的success字符串能让钉钉服务器验证是否是来自用户,属于双向验证

2.2接口开发

2.2.1技术选型

对于创建接口服务这种需求,钉钉开放官网文档有些示例,比如Java等,都是可以实现的,因为需求很简单,故采用胶水语言Python进行开发

Python中想做接口服务,有两种选择,一是Flask轻量化接口服务,二是Django(Python Web框架)。经过简单比较后,选择Flask轻量化接口服务

主要代码如下:

DingDingCallback

# !/usr/bin/python3
# encoding:utf-8
'''
dingding通讯录同步至企业微信
采用Flask写一个实时监听的接口
收到钉钉的通讯录变更请求后,修改请求中的数据直接请求企业微信通讯录相关的API
'''
import flask
import json
import DingTalkCrypto

# 钉钉事件订阅aeskey
aes_key = "xxxxxxxxxxxxxT329ssbVn5Bo"
# 钉钉事件订阅token
token = "xxxxxxxxxxxIgBpKsBE"
# 钉钉appkey
app_key = "xxxxxxxxxxxxxxx"


# 实例化api,把当前这个python文件当作一个服务,__name__代表当前这个python文件
api = flask.Flask(__name__)


# 'index'是接口路径,methods不写,默认get请求
@api.route('/sync/test', methods=['get'])
# get方式访问
def index():
    ren = {'msg': '成功访问首页', 'msg_code': 200}
    print("测试接口成功请求!!!")
    # json.dumps 序列化时对中文默认使用的ascii编码.想输出中文需要指定ensure_ascii=False
    return json.dumps(ren, ensure_ascii=False)


# post方式访问(josn格式参数)
@api.route('/sync', methods=['post'])
def loginjosn():
    # 1.通过flask获取请求中的参数列表
    args = flask.request.args
    # 2.获取需要解密的参数
    signature = args.get('signature')  # 实际打印中signature和msg_signature是一样的
    msg_signature = args.get('msg_signature')
    timestamp = args.get('timestamp')
    nonce = args.get('nonce')
    # 3.获取post请求中的json数据
    encrypt = flask.request.json.get('encrypt')
    print(encrypt)

    # 4.调用加密解密工具类
    # DingCallbackCrypto3是官方提供的demo: https://github.com/open-dingtalk/dingtalk-callback-Crypto
    # 参数说明:
    '''
    1.token为应用中事件订阅下的签名token的数据
    2.aes_key为应用中事件订阅下的加密aes_key的数据
    3.app_key为应用的应用信息中的AppKey的数据
    注意:(这块需要具体问题具体分析,可以参考官方文档)
        1.开发者后台配置的订阅事件为应用级事件推送,此时app_key参数为应用的APP_KEY
        2.当使用HTTP回调注册接口方式接收钉钉推送的订阅事件时,是以企业为维度推送的,app_key为CorpId
    '''
    dingCrypto = DingTalkCrypto.DingTalkCrypto3(token, aes_key, app_key)
    # 5.解密回调事件
    decrypt_msg  = dingCrypto.getDecryptMsg(msg_signature, timestamp, nonce, encrypt)
    print(decrypt_msg)  # 打印结果: {"EventType":"check_url"}
    # 6.必须返回一个加密的success,让钉钉服务器进行确认
    success_map = dingCrypto.getEncryptedMap("success")
    return success_map


if __name__ == '__main__':
    api.run(port=6666, debug=True, host='0.0.0.0')  # 启动服务
    # debug=True,改了代码后,不用重启,它会自动重启
    # 'host='0.0.0.0'可以被所有请求访问到

DingTalkCrypto

需要注意的是:

众所周知,Crypto是个老坑包了,如果下载不了Crypto或者说用不了Crypto类库,那么需要下载pycryptodome

pycryptopycrytodomecrypto是一个东西,crypto在python上面的名字是pycrypto,它是一个第三方库,但是已经停止更新四年了(截止到2023-02-16),所以不建议安装这个库

下载pycryptodome库之前需要把之前安装的crypto库都卸载干净

pip uninstall pycrypto
pip uninstall cryptography
pip uninstall crypto
pip uninstall pycryptodome
pip install pycryptodome
# -*- coding: utf-8 -*-
# 依赖Crypto类库
# API说明
# getEncryptedMap 生成回调处理成功后success加密后返回给钉钉的json数据
# decrypt  用于从钉钉接收到回调请求后

import time
import io, base64, binascii, hashlib, string, struct
from random import choice
from Crypto.Cipher import AES

"""
@param token          钉钉开放平台上,开发者设置的token
@param encodingAesKey 钉钉开放台上,开发者设置的EncodingAESKey
@param corpId         企业自建应用-事件订阅, 使用appKey
                      企业自建应用-注册回调地址, 使用corpId
                      第三方企业应用, 使用suiteKey
"""
class DingTalkCrypto3:
    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):
        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):
        """
        解密
        :param content:
        :return:
        """
        sign = self.generateSignature(nonce, timeStamp, self.token,content)
        print(sign, msg_signature)
        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(binascii.hexlify(decodeRes[-1]),16)
        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]
        # 获取去除初始向量,四位msg长度以及尾部corpid
        nl = len(decodeRes)

        if decodeRes[(20+l):].decode() != self.key:
            raise ValueError('corpId 校验错误')
        return decodeRes[20:(20+l)].decode()

    def encrypt(self, content):
        """
        加密
        :param content:
        :return:
        """
        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())
        return base64.encodebytes(aesEncrypt).decode('UTF-8')

    # 生成回调返回使用的签名值
    def generateSignature(self, nonce, timestamp, token, msg_encrypt):
        print(type(nonce), type(timestamp), type(token), type(msg_encrypt))
        v = msg_encrypt
        signList = ''.join(sorted([nonce, timestamp, token, v]))
        return hashlib.sha1(signList.encode()).hexdigest()

    def length(self, content):
        """
        将msg_len转为符合要求的四位字节长度
        :param content:
        :return:
        """
        l = len(content)
        return struct.pack('>l', l)

    def pks7encode(self, content):
        """
        安装 PKCS#7 标准填充字符串
        :param text: str
        :return: str
        """
        l = len(content)
        output = io.StringIO()
        val = 32 - (l % 32)
        for _ in range(val):
            output.write('%02x' % val)
        # print "pks7encode",content,"pks7encode", val, "pks7encode", output.getvalue()
        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):
        """
        生成加密所需要的随机字符串
        :param size:
        :param chars:
        :return:
        """
        return ''.join(choice(chars) for i in range(size))


if __name__ == '__main__':
    dingCrypto = DingTalkCrypto3("xxxxxxxx", "xxxxxxxxxxxxxxxxxx", "xxxxxxxxxxx")
    success = dingCrypto.encrypt("success")
    print(success)

2.3接口验证

写好接口服务后放到有公网IP的地方运行(直接在python或者conda虚拟python环境下运行py文件、打包成可执行文件执行均可)

将服务接口复制到钉钉应用中的事件与回调中的请求网址中,点击保存即可

需要注意的是:

在开发过程中会测试事件订阅是否完成,高频率的刷新网页可能会导致事件订阅的aes_key和token被刷新或者没刷新但是因为缓存的问题,会有偏差,需要注意事件订阅中的两个值和接口服务代码中的两个值要一一对应起来,如果对应有误的话,保存的时候会报错