附录

优质
小牛编辑
135浏览
2023-12-01

附录


PDF版下载

签名signature校验

signature是签名,用于验证调用者的合法性

校验流程

  1. 将rn、timestamp、Token三个参数字符串拼接成一个字符串后进行md5
  2. 加密后获得的字符串与请求中的signature对比,标识该请求来源于如流平台

同时还可通过记录signature、timestamp防止重放攻击。

参数说明

参数类型参数位置是否必须说明
rnIntegerQuery参数请求中的rn
timestampIntegerQuery参数请求中的timestamp
TokenStringQuery参数管理后台应用详情-开发信息-回调配置获取的Token值

参考代码:

private static boolean checkSignature(HttpServletRequest request) throws Exception {
    String signature = request.getParameter("signature");
    String timestamp = request.getParameter("timestamp");
    String rn = request.getParameter("rn");
    String str = DigestUtils.md5Hex((rn + timestamp + Access_Token).getBytes("utf-8"));

    return signature.equals(str);
}

消息/事件内容解密

如流企业平台将消息或者事件明文经过AES加密,然后对加密后内容进行Base64编码成密文,密文为Encrypt_Msg = Base64_Encode(AES_Encrypt(msg)),msg为消息或事件明文

加密算法:AES-128
加密模式:ECB
填充方式:PKCS5(PKCS7兼容)
编码方式:Base64URLSafe(它不在末尾填充=号,并将标准Base64中的+和/分别改成了-和_)

解密流程

  1. Base64解码,Encrypt_Raw_Msg = Base64URLSafeDecode(Encrypt_Msg) 特别提醒:某些语言(比如Python)Base64URLSafeDecode方法不能处理尾部缺失=的情况,需要自行补齐=(len(Encrypt_Msg)% 4)
  2. AES解密,msg = AES_Decrypt(Encrypt_Raw_Msg, AESKey)

参数说明

参数类型参数位置是否必须说明
EncodingAESKeyStringRequestBody企业平台生成,长度固定为22个字符
AESKeyStringRequestBodyAES算法的密钥
AESKey = Base64_Decode(EncodingAESKey + "=="),长度为16字节。

各语言示例: 主要包含signature校验和加解密示例,加密的消息或事件服务号消息机器人消息本身内容的获取请查看对应文档。

JAVA示例

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.digest.DigestUtils;

String echostr = httpServletRequest.getParameter("echostr");
// 配置回调地址时应回调服务就绪,配置回调地址时会调用配置地址,需要回显才能校验通过
if (echostr != null && echostr.length() != 0) {
    if (checkSignature(request)) {
        httpServletResponse.getWriter().print(echostr);
    }
} else {
    byte[] msgBase64 = Base64.decodeBase64(IOUtils.toString(httpServletRequest.getInputStream(), "utf-8"));
    Charset charset = Charset.forName("utf-8");
    SecretKeySpec skeySpec = new SecretKeySpec(Base64.decodeBase64(encodingAESKey), "AES");
    Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, skeySpec);
    byte[] decrypted = cipher.doFinal(msgBase64);
    // 通过AES解密后得到回调消息数据
    String msgjsonStr = new String(decrypted, charset);
}

Python示例

#!/usr/bin/python
# -*- coding: utf-8 -*-

import base64
import binascii

import six
# pip install pycrypto
# Windows No module named Crypto.Cipher问题,https://www.jianshu.com/p/09a14a61b454
from Crypto.Cipher import AES


def base64_urlsafe_decode(s):
    """
    base64 解码(urlsafe兼容模式)
    :return:
    """
    # 系统的urlsafe_b64decode方法不支持补'='
    s = s.replace('-', '+').replace('_', '/') + '=' * (len(s) % 4)

    return base64.b64decode(s)


class AESCipher(object):
    """
    AES加解密类
    """

    def __init__(self, key, mode=AES.MODE_ECB, padding='PKCS7', encode='base64', **kwargs):
        """
        初始化
        :param key:
        :param mode:
        :param padding: 数据填充方式 PKCS7、ZERO
        :param encode: 数据编码方式 raw、base64、hex
        """
        self.key = key
        self.mode = mode
        self.padding = padding
        self.encode = encode
        self.kwargs = kwargs

        self.bs = AES.block_size

        self.IV = self.kwargs.get('IV', None)
        if self.IV and self.mode in (AES.MODE_ECB, AES.MODE_CTR):
            raise TypeError("ECB and CTR mode does not use IV")

    def _aes(self):
        return AES.new(self.key, self.mode, **self.kwargs)

    def encrypt(self, plaintext):
        """
        加密
        :param plaintext:
        :return: py3返回 byte string, py2返回str
        """
        # padding https://en.wikipedia.org/wiki/Padding_(cryptography)#PKCS#5_and_PKCS#7
        if self.padding == 'PKCS7':
            pad = lambda s: s + (self.bs - len(s) % self.bs) \
                            * chr(self.bs - len(s) % self.bs).encode('utf-8')
        else:
            pad = lambda s: s + (self.bs - len(s) % self.bs) \
                            * '\x00'
        # 统一为字节类型
        if isinstance(plaintext, six.text_type):
            plaintext = plaintext.encode('utf-8')

        # 注意:加密、解密需单独实例化
        raw = self._aes().encrypt(pad(plaintext))

        if self.encode == 'hex':
            return binascii.hexlify(raw)
        if self.encode == 'base64':
            return base64.b64encode(raw)
        return raw

    def decrypt(self, ciphertext):
        """
        解密
        :param ciphertext:
        :return: py3返回 byte string, py2返回str
        """
        if not ciphertext:
            return None

        if self.padding == 'PKCS7':
            if six.PY3:
                unpad = lambda s: s[0:-s[-1]]
            else:
                unpad = lambda s: s[0:-ord(s[-1])]
        else:
            unpad = lambda s: s.rstrip('\x00')

        # 统一为文本字符类型
        if isinstance(ciphertext, six.binary_type) and self.encode != 'raw':
            ciphertext = ciphertext.decode('utf-8')
        if self.encode == 'hex':
            ciphertext = binascii.unhexlify(ciphertext)
        if self.encode == 'base64':
            ciphertext = base64_urlsafe_decode(ciphertext)

        return unpad(self._aes().decrypt(ciphertext))


if __name__ == '__main__':
    # flask示例(推荐>=1.1.0),仅核心逻辑代码,其它web框架类比参考
    from flask import request

    # 获取POST参数echostr,Django可使用request.POST.get('echostr', None)
    # 注意:仅回调配置的时候有此参数,需要指定缺省值
    echostr = request.form.get('echostr', None)
    # 配置回调地址时应回调服务就绪,配置回调地址时会调用配置地址,需要回显才能校验通过
    if echostr:
        if check_signature(signature, rn, timestamp, token):
            return echostr
        else:
            return 'check signature fail'
    else:
        # 获取request raw body, Django可使用request.body
        msg_base64 = request.get_data()
        encrypter = AESCipher(base64_urlsafe_decode(encodingAESKey))
        # 通过AES解密后得到回调消息数据
        decrypted = encrypter.decrypt(msg_base64)
        msg_data = json.loads(decrypted)

PHP示例

<?php

class Encrypter
{
    private $method;
    private $password;
    private $padding;
    private $iv;
    private $lastErrorMessage;  // 最后一次失败的信息

    const AES_BLOCK_SIZE = 16;

    /**
     * Encrypter constructor.
     *
     * @param string $password aes_key
     * @param string $method 加密的模式
     * @param string $padding 填充数据模式, 支持ZERO、PKCS7(兼容PKCS5)
     * @param string $iv 初始向量, 默认使用AES-128-ECB初始向量为空
     */
    function __construct($password, $method = 'AES-128-ECB', $padding = 'PKCS7', $iv = '')
    {
        $this->password = $password;

        $this->method = $method;
        $this->padding = $padding;
        $this->iv = $iv;
    }

    /**
     * 加密
     *
     * @param string $data 需要加密的字符串
     *
     * @return false|string 失败时返回false
     */
    public function encrypt($data)
    {
        /**
         * openssl_encrypt ZERO_PADDING模式有个小问题
         * 当设置为OPENSSL_ZERO_PADDING时其实是禁用padding
         * EVP_CIPHER_CTX_set_padding(&cipher_ctx, 0);
         * 因此需要构造合适长度的被加密串, 这样加密过程中就不需要padding了,不然会加密失败
         * 不过需要注意的是因为padding了\0字符 解密完成后需要trim。显而易见,若是被加密串两端本身就包含\0等字符就会丢失信息
         *
         * 参考资料: http://php.net/manual/en/function.openssl-encrypt.php#117208
         * http://php.net/manual/en/function.openssl-encrypt.php#117499
         * http://www.cnblogs.com/solohac/p/4284424.html
         * http://stackoverflow.com/questions/11838197/php-vs-java-aes-encryption-what-is-the-difference
         */
        if ($this->padding === 'ZERO') {
            $options = OPENSSL_ZERO_PADDING;
            $padLen = self::AES_BLOCK_SIZE - (strlen($data) % self::AES_BLOCK_SIZE);
            $data .= str_repeat("\0", $padLen); // zero padding
        } else {
            // 默认值, 使用PKCS7(兼容PKCS5) padding方式
            $options = 0;
        }

        try {
            $encryptedData = openssl_encrypt(
                $data, $this->method, $this->password, $options, $this->iv
            );
        } catch (Exception $e) {
            $this->lastErrorMessage = $e;

            return false;
        }

        return $encryptedData;
    }

    /**
     * 解密
     *
     * @param string $data 需要解密的字符串
     *
     * @return string|false 失败时返回false
     */
    public function decrypt($data)
    {
        if (empty($data)) {
            return null;
        }
        // 预先 decode
        // base64 url safe decode
        $data = base64_decode(str_pad(
            strtr($data, '-_', '+/'),
            strlen($data) % 4,
            '=',
            STR_PAD_RIGHT
        ));

        if ($this->padding === 'ZERO') {
            $options = OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING;
        } else {
            // PKCS7(兼容PKCS5)padding,openssl默认方式
            $options = OPENSSL_RAW_DATA;
        }

        try {
            $decryptedData = openssl_decrypt(
                $data, $this->method, $this->password, $options, $this->iv
            );
        } catch (Exception $e) {
            $this->lastErrorMessage = $e;

            return false;
        }

        // 解密后的数据两端可能有padding的空字符, 需要调用方按需trim
        return $decryptedData;
    }

    /**
     * 获取最后一次报错的信息
     *
     * @return string
     */
    public function getLastErrorMessage()
    {
        return sprintf(
            "last error: %s, openssl error: %s",
            $this->lastErrorMessage, openssl_error_string()
        );
    }
}

// 配置回调地址时应回调服务就绪,配置回调地址时会调用配置地址,需要回显才能校验通过
if (!empty($_POST['echostr'])) {
    if (check_signature($signature, $rn, $timestamp, $token)) {
        echo $_POST['echostr'];
    }
    else {
        echo "check signature fail";
    }
} else {
    $msgBase64 = file_get_contents("php://input");
    // 通过AES解密后得到回调消息数据
    $encrypter = new Encrypter(base64_decode($encodingAESKey));
    $decrypted = $encrypter->decrypt($msgBase64);
    $msgData = json_decode(trim($decrypted), true);
}

Node.js示例

const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const CryptoJS = require('crypto-js');
// 需替换成自己机器人的密匙
const EncodingAESKey = '';
class AESCipher {
    constructor(key) {
        this.key = CryptoJS.enc.Base64.parse(key);
        this.options = {
            mode: CryptoJS.mode.ECB,
            padding: CryptoJS.pad.Pkcs7
        };
    }
    // 加密
    encrypt(data) {
        const cipher = CryptoJS.AES.encrypt(data, this.key, this.options);
        const base64Cipher = cipher.ciphertext.toString(CryptoJS.enc.Base64);
        const resultCipher = base64Cipher
            .replace(/\+/g, '-')
            .replace(/\//g, '_')
            .replace(/=+$/g, '');
        return resultCipher;
    }
    // 解密
    decrypt(content) {
        content = content
            .replace(/-/g, '+')
            .replace(/_/g, '/')
            .padEnd(content.length + content.length % 4, '=')
        const bytes = CryptoJS.AES.decrypt(content, this.key, this.options);
        return bytes.toString(CryptoJS.enc.Utf8);
    }
}
const cipher = new AESCipher(EncodingAESKey);
const app = new Koa();
app.use(bodyParser({enableTypes: ['json', 'form', 'text']}));
app.use(async function (ctx, next) {
    if (ctx.method === 'POST') {
        // 获取POST参数echostr
        if (ctx.request.body.echostr) {
            if (check_signature(signature, rn, timestamp, token)) {
                // 配置回调地址时应回调服务就绪,配置回调地址时会调用配置地址,需要回显才能校验通过
                ctx.body = ctx.request.body.echostr;
            }
            else {
                ctx.body = 'check signaturefail';
            }
        } else {
            // 获取request raw body,不同框架可能略有不同
            let data = ctx.request.rawBody;
            if (!data) {
                console.error('未获取到数据对象');
            }
            // 解密
            data = cipher.decrypt(data);
            console.log('消息体内容', data);
            // 转换为对象
            data = JSON.parse(data);
        }
    } else {
        ctx.body = 'GET request OK'
    }
});

问题定位

企业平台开放接口(https://api.im.baidu.com)会在response header中带上X-Logid,为方便定位,强烈建议业务方在打印调用错误日志时同时打印X-Logid。

全局错误码

返回码说明
0请求成功
-1系统错误
40000请求参数错误
40001无权限
40013不合法的corpid
40020文件已存在
40021agentid失效
40022上报超时
40023上传成功
40025文件不完整
40026fid sessionid 不匹配
40027个人文件大小超出限制
40028应用不支持上传
40029会话id失效/不合法的oauth_code
40030fid或md5 失效
40031status ,rate 失效
40032有风险
40033当前会话中超过10个文件处理对象
40034群或讨论组文件过多
40035groupid失效
40036非企业群
40037userid数量超过50
40038所有userid均失效
40039设备未找到
40040参数错误
40041用户不存在
40042企业二次认证异常
40043员工未认证
40044群未关注
40056不合法的agentid
40080不合法的suitesecret
40082不合法的suitetoken
40083不合法的suiteid
40084不合法的永久授权码
40085不合法的suiteticket
40086重复开启
40087重复关闭
60011管理员权限不足,(user/department/agent)无权限
60012非企业获取非群成员
60013员工未认证
42003oauth_code超时或者失效
42009suitetoken失效
60003部门不存在
60004父部门不存在
60005不允许删除有成员的部门
60006不允许删除有子部门的部门
60007不允许删除根部门
60008部门名称已存在