钉钉 ISV 接入流程

1. 基本概念

相较于 Web 领域经常碰到的 OAuth2 这类简单的三方授权模型,钉钉的 ISV 接入流程涉及的东西比较多一点,所以在正式开始之前,先把过程中会见到的各种概念拿出来说一下,同时,先用用钉钉吧。

1.1. 用户user与企业corp

钉钉中的用户与企业,算是一个开放式的关联关系,通过手机号来标识一个“唯一”的用户。

一个用户可以是多个企业的成员,在具体的操作页面上,可以切换到不同企业,从而看到不同企业中可能有不同的应用。

与用户相关的关键数据有: userId 和 jobnumber , userId 是使用钉钉相关 api 时的用户标识, jobnumber 是用户在钉钉中,作为“员工”角色时的一个属性,它一般是打通钉钉与企业内部其它系统的标识。

与企业有关的关键数据,是几个配置数据, corp_id , corp_secret 这两个是换 token 时会用到的(ISV 流程中用不到)。还有一个 sso_secret ,在作后台直接登录时会用到(这个功能不是非实现不可)。

用户的角色,除了是某个企业的“员工”之后,还可以是企业的“管理员”,或是“主管理员”。具体企业的管理后台,是通过 https://oa.dingtalk.com 单独登录的。

1.2. 应用agent与套件suite

“应用”就是在具体的企业页面,默认九宫格里一个一个的图标,点击之后会跳转到特定的页面。

“套件”是多个应用,视觉上在九宫格中它们会收在一起,点击之后再在一个弹出框中展开,就像 iOS 上的那个“附加程序”。

“套件”是对 ISV 才有的概念,因为钉钉设计的 ISV 接入规则中, ISV 就是以“套件”为单位来提供“产品输出”的。在创建了一个套件之后,可以在里面创建多个应用,而且按目前来看,授权也是以套件为单位,但是企业的管理员可以停用其中的某一个应用(这里钉钉的后台在交互上还有一个 BUG,2016-5-20)。

与应用有关的数据,是 app_id 和 agent_id 。与套件有关的数据,有 suite_key,suite_secret ,这两个也是用来换 token 的,当然还涉及其它的加密解密过程。

套件中的应用,用 app_id 来标识,但是它被企业使用了,放到了某个企业中,就用 agent_id来标识了。

1.3. 签名signature与对称加密AES

签名用来防串改,钉钉用的签名算法是 sha1 ,跟其它场景一样,做法基本上也就是把几个值先排序,然后算 sha1 就好了。

AES 是对称加密算法,在钉钉服务器往 ISV 自己的应用服务器“推送”的场景,传递的信息就是AES 加密之后的密文,并且要求响应的内容也要是密文。这块如果没接触过会比较折腾的。 AES的具体实现中,会涉及到“补位”(因为 AES 是按固定长度的分块来处理,所以如果最后一块长度不够要补上),“补位”有 Zero Padding 就是用 \0 来补,或者用 PKCS #7 方法,根据最后一块长度的不同用不同的字节来补。

这块在各种语言中肯定都有现成方法,了解概念并且看文档仔细点,还是好处理的,代码也没几行。不过看文档不仔细就可能被坑哭,“补位”那里我起码被郁闷了一个小时,就是不知道哪里错了(我开始直接用 \0 来补的,而且因为解密没问题还没想到这块来)。

1.4. 应用市场与间接授权

ISV 机制是为“应用市场”而设计的,按钉钉官方的想法,作为独立服务提供商,可以开发完应用之后,上架市场,需要的企业自己到市场上来采购应用。

基于此,它的授权,通知的实现机制也就是现在这样,有些麻烦的样子了。

用户“采购应用”, 或者对应用做任何的配置性的操作,都是在钉钉的服务中完成的,但是实际提供服务的,却是具体 ISV 开发的应用。所以,企业用户,钉钉,ISV 三方之间,钉钉的服务就是一个衔接的作用,这其间的“推送”也算 ISV 方式与普通的“微应用”方式最大的不同了。(推送那里就涉及 AES 加解密)。

1.5. 为了安全?

背后依据与实际的效果不清楚,但是整个流程中因为安全的考虑,引入不少系统上的实现成本的。除了前面说的对称加密,还有一个很烦的机制是动态的 ticket 值,这个动态的 ticket 还是通过“推送”的方式来维护的。

还有为什么要使用“临时授权码”来换“永久授权码”也不是很懂,不过整个过程让我有点想起了 OAuth 1 时的痛苦。

好了,我们正式开始吧。

2. 第一步,注册

这一步按官方文档操作就好了。不需要自己单独去申请“企业”,在 ISV 的后台,可以专门创建测试用的企业,并且把未上架的套件对这些你自己创建的测试企业授权。

http://g.alicdn.com/dingding/opendoc/docs/_isvguide/tab3.html

通过这个文档,进到“阿里云”的后台 http://console.d.aliyun.com/ ,然后创建一个钉钉的套件。这个过程可能需要附加阿里云的开发者认证,但是事实上它跟阿里云完全没关系的。

好吧,如果你最后已经走到“创建套件”这里了,那么就可以了,不出意外的话,你是没法简单地把一个套件成功创建的,因为那个“回调URL”需要被即时检查,并通过,这就是下面我们要讲到的内容。

3. 第二步,被动回调处理

在创建套件时,填写的“回调URL”那里,需要通过有效性的检查,这一步就需要在服务器上完成对推送消息的处理(你点“验证有效性”时就马上会有一个请求出去,调试还是比较方便的)。

“验证有效性”是 http://ddtalk.github.io/dingTalkDoc/#2-回调接口(分为五个回调类型) 这里的第一个回调,在你填写的地址中,会收到一个 POST 请求,这个请求的完整样子大概像:

POST /callback?signature=111108bb8e6dbce3c9671d6fdb69d15066227608&timestamp=1783610513&nonce=380320111 HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: xxx

{
    "encrypt":"1ojQf0NSvw2WPvW7LijxS8UvISr8pdDP+rXpPbcLGOmIBNbWetRg7
               IP0vdhVgkVwSoZBJeQwY2zhROsJq/HJ+q6tp1qhl9L1+ccC9ZjKs1
               wV5bmA9NoAWQiZ+7MpzQVq+j74rJQljdVyBdI/dGOvsnBSCxCVW0I
               SWX0vn9lYTuuHSoaxwCGylH9xRhYHL9bRDskBc7bO0FseHQQasdfghjkl"
}

( encrypt 的换行是我自己处理的)

对这个请求的所有处理,官方文档在 http://ddtalk.github.io/dingTalkDoc/#消息体签名 。

简单来说,我们自己写的服务接下来需要做三件事:

  • 检查签名
  • 解密消息
  • 把响应内容加密再返回

3.1. 检查签名

每个对回调地址的请求,都会带上签名,我们需要按排序规则计算出签名值,再与请求中的签名值作比对。参与签名运算的有 4 个值:

  • SUITE_TOKEN ,套件的 token 配置,就是在创建套件时我们自己填写的一段字符串。
  • timestamp ,时间戳,在请求的 GET 参数中( 1783610513 )。
  • nonce , 噪声值,在请求的 GET 参数中( 380320111 )。
  • encrypt ,请求的 body 中,按 json 解编码,取的 encrypt 的属性值( 1ojQf...hjkl)。

计算签名的方法如下:

from hashlib imoprt sha1

def sign(self, stamp, nonce, str):
    '签名计算'

    arg = [TOKEN, stamp, nonce, str]
    arg.sort()
    s = ''.join(arg)
    return sha1(s).hexdigest()

得到的结果应该与 GET 参数中的 signature 参数的值 11...608 一致。

3.2. 解密信息

第二步,钉钉服务器给的真正的信息是被加密后放到 encrypt 中的,所以我们要获取到真正有用的信息需要解密密文。

加解密使用 AES 算法,加密时以 PKCS#7 方式处理“补位”填充。解密时可以不用管“补位”的情况,因为原文还不是直接就是业务信息,原文本身还是一个“结构体”,业务信息是其中的一段字节。

先解密,密文是(换行是我自己处理的):

{
    "encrypt":"1ojQf0NSvw2WPvW7LijxS8UvISr8pdDP+rXpPbcLGOmIBNbWetRg7I
               P0vdhVgkVwSoZBJeQwY2zhROsJq/HJ+q6tp1qhl9L1+ccC9ZjKs1wV
               5bmA9NoAWQiZ+7MpzQVq+j74rJQljdVyBdI/dGOvsnBSCxCVW0ISWX
               0vn9lYTuuHSoaxwCGylH9xRhYHL9bRDskBc7bO0FseHQQasdfghjkl"
}

中的 encrypt 的属性值,就是 1ojQf..hjkl 这段字符。

AES 算法需要一个密钥,它在创建件时的“数据加密密钥”这里,当然,页面上显示的是密钥base64 编码之后的样子,我们使用时要作 decode (先在后面加一个等号 = 再 decode)。

解密都有现成的实现,用起来很简单了:

from Crypto.Cipher import AES
AES_KEY = ('<数据加密密钥>' + '=').decode('base64')
IV = 'x' * 16

def decrypt(self, str):
    '解密'
    aes = AES.new(AES_KEY, AES.MODE_CBC, IV)
    s = aes.decrypt(str)
    return s

IV 是随便一个长度为 16 的字符串就可以了。解密之后,得到的是一个“结构体”。

3.3. 拆结构体

上一部解密出来的“字节串”是一个“结构体”,它的构成是:

16字节随机串 + 4字节表示消息长度(网络序) + N字节的消息 + SUITE_KEY的值 + 补位字节

我们的目标只是那 N字节的消息 ,所以先从 16:20 的位置取出 4 个字节,按网络序解成整数,比如是 N ,再从 20 的位置开始往后取 N 个字节就好了。

import struct

msg_len = struct.unpack('!I', s[16:20])[0]
return s[20 : 20 + msg_len]

s 是解密出来的“字节串”。

把这一步合到上面的 decrypt 方法中就是:

def decrypt(self, str):
    '解密'

    aes = AES.new(AES_KEY, AES.MODE_CBC, IV)
    s = aes.decrypt(str)
    msg_len = struct.unpack('!I', s[16:20])[0]
    return s[20 : 20 + msg_len]

上面的 encrypt 拆出来之后,最后得到的是一个 json 字符串,大概像:

{

  "EventType":"check_create_suite_url",
  "Random":"brdkKLMW",
  "TestSuiteKey":"suite4xxxxxxxxxxxxxxx"
}

3.4. 拼结构体与加密

说完了解密,再说加密。代码也很简单了:

def encrypt(self, str):
    '加密'

    aes = AES.new(AES_KEY, AES.MODE_CBC, IV)
    msg_len = struct.pack('!I', len(str))
    s = [uuid.uuid4().hex[:16], msg_len, str, SUITE_KEY]
    s = ''.join(s)

    # 补位
    s = self.padding(s)

    s = aes.encrypt(s)
    return s.encode('base64')

在拼结构体时,我们用 uuid 来产生随机值,用“无符号整型”类型的数据结构(网络序)来处理长度为 4 字节的一个数字。最后把这些直接拼在一起就好了(Python 2.x 的 String 类型就是“字节”, Unicode 类型是“字符”)。

上面的 padding 方法要说一下:

def padding(self, str):
     'PKCS7补位'

     block_size = 32
     count = block_size - len(str) % block_size
     if count == 0:
         count = block_size

     return str + count * chr(count)

以 32 为块长,最后差多少补多少,如果是 32 的整数倍则补 32 个字节。而用来填充的单字节内容,就是“差值”。

3.5. 完整过程

官方在 http://ddtalk.github.io/dingTalkDoc/#调试工具 这里提供了一套符合计算规则的例子,我们可以用这里的数据来验证我们的代码是否正确。这个页面中给出的数据有:

  • signature 需要比对的签名值: 5a65ceeef9aab2d149439f82dc191dd6c5cbe2c0
  • timestamp 时间戳: 1445827045067
  • nonce 噪声值: nEXhMP4r
  • SUITE_TOKEN token 值: 123456
  • base64后的AES密钥: 4g5j64qlyl3zvetqxz5jiocdr586fn2zvjpa8zls3ij
  • SUITE_KEY: suite4xxxxxxxxxxxxxxx
  • 加密的内容(换行是我自己处理的):
1a3NBxmCFwkCJvfoQ7WhJHB+iX3qHPsc9JbaDznE1i03peOk1LaOQoRz3+nlyGNhwmwJ3vDMG+OzrHMeiZI7gT
RWVdUBmfxjZ8Ej23JVYa9VrYeJ5as7XM/ZpulX8NEQis44w53h1qAgnC3PRzM7Zc/D6Ibr0rgUathB6zRHP8PY
rfgnNOS9PhSBdHlegK+AGGanfwjXuQ9+0pZcy0w9lQ==

完整的代码如下:

# -*- coding: utf-8 -*-

import uuid
import struct
import json
import time
from hashlib import sha1
from Crypto.Cipher import AES

'http://ddtalk.github.io/dingTalkDoc/#调试工具'

TOKEN = '123456'
ENCODING_AES_KEY = '4g5j64qlyl3zvetqxz5jiocdr586fn2zvjpa8zls3ij'
AES_KEY = (ENCODING_AES_KEY + '=').decode('base64')
SUITE_KEY = 'suite4xxxxxxxxxxxxxxx'
IV = 'x' * 16

def sign(stamp, nonce, str):
    '签名计算'

    arg = [TOKEN, stamp, nonce, str]
    arg.sort()
    s = ''.join(arg)
    return sha1(s).hexdigest()

def decrypt(str):
    '解密'

    aes = AES.new(AES_KEY, AES.MODE_CBC, IV)
    s = aes.decrypt(str)
    msg_len = struct.unpack('!I', s[16:20])[0]
    return s[20 : 20 + msg_len]

def padding(str):
     'PKCS7补位'

     block_size = 32
     count = block_size - len(str) % block_size
     if count == 0:
         count = block_size

     return str + count * chr(count)

def encrypt(str):
    '加密'

    if isinstance(str, unicode):
        str = str.encode('utf8')

    aes = AES.new(AES_KEY, AES.MODE_CBC, IV)
    msg_len = struct.pack('!I', len(str))
    s = [uuid.uuid4().hex[:16], msg_len, str, SUITE_KEY]
    s = ''.join(s)
    s = padding(s)
    s = aes.encrypt(s)
    return s.encode('base64')

demo = {
    'nonce': 'nEXhMP4r',
    'stamp': '1445827045067',
    'sign': '5a65ceeef9aab2d149439f82dc191dd6c5cbe2c0',
    'body': '1a3NBxmCFwkCJvfoQ7WhJHB+iX3qHPsc9JbaDznE1i03peOk1LaOQoRz3+nlyGNhwmwJ3vDM
             G+OzrHMeiZI7gTRWVdUBmfxjZ8Ej23JVYa9VrYeJ5as7XM/ZpulX8NEQis44w53h1qAgnC3P
             RzM7Zc/D6Ibr0rgUathB6zRHP8PYrfgnNOS9PhSBdHlegK+AGGanfwjXuQ9+0pZcy0w9lQ=='
}

if __name__ == '__main__':
    print sign(demo['stamp'], demo['nonce'], demo['body'])
    print decrypt(demo['body'].decode('base64'))

    info = json.loads(decrypt(demo['body'].decode('base64')))

    type = info['EventType']
    random = info['Random']
    suite_key = info['TestSuiteKey']

    return_encrypt = encrypt(random)
    nonce = uuid.uuid4().hex[:8]
    stamp = str(int(time.time() * 1000))
    return_sign = sign(stamp, nonce, return_encrypt)
    p = {
        'msg_signature': return_sign,
        'timeStamp': stamp,
        'nonce': nonce,
        'encrypt': return_encrypt,
    }
    print json.dumps(p)

3.6. 处理全部回调

其实有了上面的内容,只需要把获取到的信息解密,得到 Random 值再加密响应就好了,这样套件就能创建成功了。

套件成功之后,先在套件管理后台把 SUITE_KEY 这个值看一下,之前用的 suite4xxxxxxxxxxxxxxx只是一个临时值,在套件创建成功之后,就不用了。

目前钉钉的服务器会往“回调”地址推送的消息,一共有 7 种类型(加密的 json 字符串中EventType 属性会标识类型):

EventType 说明 响应
check_create_suite_url 验证回调 URL 的有效性,创建套件时发生 响应得到的 Random
suite_ticket 定时推送的 ticket 值,20 分钟一次 记录 ticket 值,响应 success 字符串
tmp_auth_code 推送临时授权码,在套件页面添加测试企业时发生(真实环境不知道何时发生,可能是企业添加应用时?) 记录 AuthCode值,响应 success字符串
change_auth 推送授权变更消息,比如禁用启用应用时 检查各应用状态,响应 success 字符串
check_update_suite_url 套件信息更新,套件信息改变时发生 响应得到的 Random
suite_relieve 企业解除授权 清理相关数据,响应 success 字符串
check_suite_license_code 不知道什么时候会用到 成功的话响应success 字符串

虽然 7 种类型看起来有些多,但是因为它们的结构都是一样的,所以代码写起来没多少:

class DingDingIsvCallbackHandler(DingDingIsvBaseHandler):

    def return_encrypt(self, s):
        '响应加密后的内容'

        stamp = str(int(time.time() * 1000))
        nonce = uuid.uuid4().hex[:8]
        encrypt = self.encrypt(s)
        sign = self.sign(stamp, nonce, encrypt)
        p = {
            'msg_signature': sign,
            'timeStamp': stamp,
            'nonce': nonce,
            'encrypt': encrypt,
        }
        self.finish(p)

    def check_create_suite_url(self, info):
        '创建套件时的检查'
        self.return_encrypt(info['Random'])

    def suite_ticket(self, info):
        '定时推送的 ticket'

        suite_key = info['SuiteKey']
        # 这个 suite_key 要保存下来
        self.return_encrypt('success')

    def tmp_auth_code(self, info):
        '企业的临时码'

        tmp_code = info['AuthCode']
        # 保存下来,或者在这里就去换永久授权码了
        self.return_encrypt('success')

    def change_auth(self, info):
        '授权变更'

        corp_id = info['AuthCorpId']
        self.return_encrypt('success')

    def check_update_suite_url(self, info):
        '套件信息变更'

        self.return_encrypt(info['Random'])

    def suite_relieve(self, info):
        '企业取消了授权'

        corp_id = info['AuthCorpId']
        self.return_encrypt('success')

    def check_suite_license_code(self, info):
        '检查序列号'

        self.return_encrypt('success')

    @web.asynchronous
    def post(self):
        sign = self.get_argument('signature', '')
        nonce = self.get_argument('nonce', '')
        stamp = self.get_argument('timestamp', '')

        try:
            encrypt = json.loads(self.request.body)['encrypt']
        except:
            self.finish('error')
            return

        check_sign = self.sign(stamp, nonce, encrypt)
        if check_sign != sign:
            self.finish('error')
            return

        info = self.decrypt(encrypt.decode('base64'))
        info = json.loads(info)

        logger.info('DingDingISV:'+ str(info))

        type = info['EventType']

        method_map = {
            'check_create_suite_url': self.check_create_suite_url,
            'suite_ticket': self.suite_ticket,
            'tmp_auth_code': self.tmp_auth_code,
            'change_auth': self.change_auth,
            'check_update_suite_url': self.check_update_suite_url,
            'suite_relieve': self.suite_relieve,
            'check_suite_license_code': self.check_suite_license_code,
        }

        return method_map[type](info)

至此,我们的服务就可以接收钉钉服务器的消息推送了。重点是我们可以得到 SUITE_TICKET 值了,后面会看到,其它的会在运算中用到的配置项都可以在一个配置文件中写死,只有这个 ticket 值是动态的。

4. 第三步,服务端主动调用

上面做完,ISV 接入中特别的地方也就差不多了,剩下的东西跟普通的自建微应用没有大的区别,基本上就是拿 token 请求 api 取数据的套路,只是 ISV 接入在流程上多几步。

这部分的官方文档在: http://ddtalk.github.io/dingTalkDoc/#isv接入开发指南 。

(下面所有的“服务地址”,都是以 https://oapi.dingtalk.com/service 开头的)

要做的事 用到的钉钉服务地址 请求的参数 响应
获取套件的suite_access_token /get_suite_token suite_key , suite_secret,suite_ticket suite_access_token
获取企业的永久授权码 /get_permanent_code suite_access_token,tmp_auth_code permanent_code
获取企业的基本信息 /get_auth_info suite_access_token,permanent_codesuite_key,auth_corpid 企业基本信息,包括套件中的各应用在这个企业的agent 信息
获取企业中的本套件中的具体应用的状态 /get_agent suite_access_token,permanent_codesuite_key,auth_corpidagentid 应用信息,包括是否“激活”的状态
激活某企业中的套件 /activate_suite suite_access_token,permanent_codesuite_key,auth_corpid (无)
获取企业的corp_access_token /get_corp_token suite_access_token,permanent_codeauth_corpid corp_access_token

说一下上面每一个操作拿到的东西都有什么用:

  • suite_access_token , ISV 行为的所有 api 都需要用它。
  • permanent_code ,以 ISV 角色去获取指定企业的相关信息需要用它(直到拿到企业的corp_access_token)。
  • corp_access_token ,这东西的作用跟“微应用”方式下我们自己取得的 access_token 是一样的,用它就可以直接去使用钉钉的其它 api 了。

看了上面的列表,是不是特别想吐槽那重复的“请求参数”,本来嘛,一个 suite_access_token 中其实已经包含了 suite_key 这个信息。同理, permanent_code 中也是包含了 auth_corpid 信息。那么suite_access_token + permanent_code 已经表达清楚了我是哪个套件,要针对哪个企业进行操作。

再补充说几点:

  • 至少在测试企业中,只是在套件后台添加了一个测试企业的话,在测试企业的管理后台还是看不到套件应用的。需要“激活”之后,才能在企业的后台看到添加的套件应用,并进行配置,或者停用其中的某些应用。
  • suite_ticket 是动态变化的,钉钉的服务器会通过“回调地址”定时主动推送,需要应用自己保存。
  • suite_access_token 和 corp_access_token 在调用 api 时会频繁用到,在获取时钉钉服务会一并响应一个“有效期”,在有效期内 access_token 是可以重复使用的,所以应用系统应该自己缓存并维护 access_token 的更新。

5. 第四步,容器页面与 jsapi

上面进行至获取到企业的 access_token 之后,剩下的事跟普通的自建“微应用”就一样了。

但是在这之前,从用户的页面上去考虑流程,还有一点是我们需要去解决的,页面中的 jsapi 在使用时某些功能是依赖 dd.config 的,而 dd.config 需要的信息中,有 corp_id 企业 ID 和 agent_id应用 ID 这两个。在自建微应用的方式下, corp_id 和 agent_id 都是确定的,我们完全可以写死在应用系统的配置中。

换到 ISV 流程下的话, corp_id 和 agent_id 都不是确定的,那么就需要我们在交互流程中去确定这两个信息,才能让 dd.config 完成,进而让页面有完整的 jsapi 能力。

哪里去弄 corp_id 和 agent_id 呢?

http://ddtalk.github.io/dingTalkDoc/#免登服务 从这里,能看到官方“补丁”式的一个解决方法,在应用的地址中,显式地用“占位符”的方法标识当前的 corp_id ,这样就可以通过地址来给到 corp_id信息了。

所以在套件后台,我们把应用的主页地址写成: http://example.com/index?corp=$CORPID$ 这样。之后不管是后端的模板直接在页面渲染 $CORPID$ 的值,还是前端解析 location.href 获取这个值,反正dd.runtime.permission.requestAuthCode 的正确执行是没有问题了( requestAuthCode 不需要 dd.config也可以的),拿到 code 之后,可以进而得到当前用户在当前企业的, userId 。

再回到 corp_id 的话题,如果要完全能力的 jsapi ,那么需要 dd.config ,它需要有签名。在后端作签名时,需要 corp_access_token ,而得到 corp_access_token 也是需要 corp_id (这里假设前面的流程都没有问题,已经得到了对应企业的 permanent_code)。

考虑到 URL 参数中加塞一个 corp 参数的不确定性(不同页面切换时总要小心),同时考虑我们纠结的 corp_id 只会在钉钉场景中出现(这个条件可以确定用户访问某个页面一定可以“自动完成登录”),所以:

  • 单独的一个登录页, URL 带 corp_id 。
  • 后端的 jsapi 签名, code 换用户信息这两个服务,以参数形式显式接收 corp_id 。
  • 在单独的登录页,前端通过直接解析 URL 来得到 corp_id ,以作请求服务时使用。
  • 登录完成之后,后端把 corp_id 保存到当前用户的“会话”中。后面的服务不再需要显式地传递 corp_id 。

上面我们解决了 corp_id 的问题。现在说 dd.config 还需要的 agent_id 这个值,就是这个应用在这个企业中的 id 。

在处理上,套件后台配置具体应用的主页地址时,我们可以把套件应用的 appid (应用的独立 ID,没有挂在某个企业下时)写到 URL 当中,请求上面说的两个服务时也带上,后台通过http://ddtalk.github.io/dingTalkDoc/#6-获取企业授权的授权数据 这个服务,可以在比对 appid 之后得到agent_id 信息。

把上面的四点改进为:

  • 单独的一个登录页, URL 带 corp_id 和 app_id 信息。(套件应用的主页地址)其中corp_id 使用 $CORPID$ 获取, app_id 是写死的确定值。
  • 后端的 jsapi 签名, code 换用户信息,这两个服务,以参数形式显式接收 corp_id 和app_id ,响应时给到前端 agent_id 。
  • 在单独的登录页,前端通过直接解析 URL 来得到 corp_id 和 app_id,以作请求服务时使用。
  • 登录完成之后,后端把 corp_id 保存到当前用户的“会话”中。后面的服务不再需要显式地传递 corp_id 。

其中前端通过 jsapi 拿到的 code 就可以作为其登录的一个凭证。

整个过程后端有两个服务,前端一个页面。

获取签名的参考:

class DingDingIsvJsapiSignHandler(DingDingIsvBaseHandler):

    TICKET_URL = 'https://oapi.dingtalk.com/get_jsapi_ticket'

    @gen.engine
    def get_ticket(self, token, callback):
        '通过 access_token 获取 jsapi_ticket'

        url = self.TICKET_URL + '?' + 'access_token=' + token
        response = yield gen.Task(AsyncHTTPClient().fetch, url)
        ticket = json.loads(response.body)['ticket']
        callback(ticket)

    def jsapi_sign(self, ticket, url):
        'jsapi 需要的签名'

        stamp = int(time.time())
        nonce = uuid.uuid4().hex
        p = {
            'noncestr': nonce,
            'jsapi_ticket': ticket.encode('utf8'),
            'timestamp': str(stamp),
            'url': url,
        }

        keys = p.keys()
        keys.sort()
        pair = [ (k, p[k]) for k in keys]
        pair = '&'.join('{}={}'.format(*p) for p in pair)
        sign = sha1(pair).hexdigest()
        return sign, stamp, nonce

    @web.asynchronous
    @gen.engine
    def get(self):
        '获取指定页面的钉钉 jsapi 的签名'

        url = self.get_argument('url', '')
        corp_id = self.get_argument('corp_id', '')
        app_id = self.get_argument('app_id', '')

        corp = self.session.query(Corp).filter_by(corp_id=corp_id, suite_key=SUITE_KEY).first()
        if not corp:
            self.finish({'code': 1, 'msg': u'不存在的企业'})
            return

        token = yield gen.Task(self.get_corp_access_token, corp_id, corp.permanent_code)
        ticket = yield gen.Task(self.get_ticket, token)
        sign, stamp, nonce = self.jsapi_sign(ticket, url)

        response = yield gen.Task(self.get_corp_auth_info, corp_id, corp.permanent_code)
        agent_list = response['auth_info']['agent']
        agent_id = ''
        for agent in agent_list:
            if str(agent['appid']) == app_id:
                agent_id = agent['agentid']

        data = {
            'agent_id': agent_id,
            'corp_id': corp_id,
            'timestamp': stamp,
            'nonce': nonce,
            'sign': sign,
        }

        self.finish({'code': 0, 'data': data})

获取当前用户信息:

class DingDingIsvLoginHandler(DingDingIsvBaseHandler):
    '通过 code 来获取当前用户信息并登录'

    USER_INFO_URL = 'https://oapi.dingtalk.com/user/getuserinfo'
    USER_DETAIL_URL = 'https://oapi.dingtalk.com/user/get'

    @web.asynchronous
    @gen.engine
    def get(self):
        code = self.get_argument('code', '')
        corp_id = self.get_argument('corp_id', '')
        corp = self.session.query(Corp).filter_by(corp_id=corp_id, suite_key=SUITE_KEY).first()
        if not corp:
            self.finish({'code': 1, 'msg': u'不存在的企业'})
            return
        token = yield gen.Task(self.get_corp_access_token, corp_id, corp.permanent_code)

        # 拿 userId
        p = {
            'access_token': token,
            'code': code,
        }
        url = self.USER_INFO_URL + '?' + urllib.urlencode(p)
        response = yield gen.Task(AsyncHTTPClient().fetch, url)
        response = json.loads(response.body)
        userId = response['userid']

        # 拿更多的信息
        p = {
            'access_token': token,
            'userid': userId,
        }
        url = self.USER_DETAIL_URL + '?' + urllib.urlencode(p)
        response = yield gen.Task(AsyncHTTPClient().fetch, url)
        obj = json.loads(response.body)

        # 做登录的事
        # 把 corp_id 写到会话中

        self.finish({'code': 0, 'data': obj})

前端页面:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>钉钉 ISV 登录</title>
<script type="text/javascript" src="http://g.alicdn.com/ilw/ding/0.8.6/scripts/dingtalk.js"></script>
<script type="text/javascript" src="http://s.zys.me/js/jq/jquery.min.js"></script>
</head>
<body>
  <h1>正在登录 ...</h1>
  <div id="msg"></div>

  <script type="text/javascript">
    $(function(){
      var corpId = location.href.match(/corp_id=(\w*)/)[1];
      var appId = location.href.match(/app_id=(\d*)/)[1];

      $.ajax({
        url: '/dingding-isv/jsapi-sign',
        dataType: 'jsonp',
        data: {corp_id: corpId, app_id: appId, url: location.href},
        success: function(response){
          var info = response.data;

          dd.config({
            agentId: info.agent_id, // 必填,微应用ID
            corpId: info.corp_id,//必填,企业ID
            timeStamp: info.timestamp, // 必填,生成签名的时间戳
            nonceStr: info.nonce, // 必填,生成签名的随机串
            signature: info.sign, // 必填,签名
            jsApiList: [
              'runtime.permission.requestAuthCode',
            ]
          });

          dd.ready(function(){
            dd.runtime.permission.requestAuthCode({
              corpId: info.corp_id,
              onSuccess: function(result) {
                var code = result.code;

                $.ajax({
                  url: '/dingding-isv/login',
                  data: {code: code, corp_id: corpId},
                  dataType: 'json',
                  success: function(response){
                    var user = response.data;
                    var value = [user.name, user.jobnumber, user.userId];
                    $('#msg').html(value.join('<br />')).css('font-size', '40px');
                  }
                });

              },
              onFail : function(err) {
                alert('出错了, ' + err);
              }
            });
          });

        }
      });

    });

  </script>
</body>
</html>

6. 整体流程示意图

时间: 2024-10-01 18:48:59

钉钉 ISV 接入流程的相关文章

钉钉 ISV 应用开发的一些心得

1. 背景 前段时间从前到后完整地做完了一个简单的钉钉上的 ISV 应用 -- 猿活动. 最开始想做这么一个小工具,是想到,平时部门中经常会组织一些分享活动,但是这些分享活动却没有一个比较直观的"站点"来记录一次又一次的,很多人的努力的付出,这是很可惜的事.同时,在做这些活动的时候,也缺少一些互动的手段,比如现场签到,打赏什么的. 好吧,刚开始的时候是这样想的,当然,在做的过程中,也发现钉钉的基于"组织"的应用场景,在某些情况下限制挻大的(比如现场的交互,因为到现场

钉钉手机端应用获取当前用户信息流程

先吐个槽,钉钉的"开发者中心"是直接对接的阿里云的后台,跳来跳去很容易懵圈,再加上钉钉的文档,它内容倒是有,但是组织方式不是按流程来的,而是按模块来的,这样的结果就是你要通过文档去了解某个完整的流程怎么处理,也要跳来跳去,转一圈下来看得都有点恶心了. 这里说的"获取当前用户信息",最有价值的一点,是获取手机端当前用户的在某企业的一个工号(至于到底是不是工号,或者其它的标识,那是管理员在后台自己维护的).有了这个用户标识,就可以实现"直接登录"等功

钉钉APP请假审批设置流程分享

给各位手机版钉钉软件的使用者们来详细的解析分享一下请假审批设置的流程. 流程分享: 1.打开微应用.   2.点击审批.   3.然后,点击请假.   4.填写好请假内容点击提交.   5.然后,审批状态就可以清晰的知道啦.   6.还可以对审批人发动DING催促哦.   7.审批人同意就可以啦.   好了,以上的信息就是小编给各位手机版钉钉的这一款软件的使用者们带来的详细的请假审批设置的流程解析分享的全部内容了,各位看到这里的朋友们,小编相信你们现在那是非常的清楚请假的方法了吧,那么各位就快去

钉钉上线企业服务功能 阿里旅行饿了么成首批接入商

10月11日下午消息,阿里钉钉今日正式上线企业服务,将为通过合作接入的形式为钉钉用户企业提供商旅.订餐.用车.健康.社保等方面的服务.阿里旅行和饿了么等成为首批接入合作商. 钉钉方面称,企业服务计划的筹备花费了3个多月时间,接入的服务内容不但包括基于工作场景的吃.住.行,还将包括企业员工体检.社保服务.法务服务.企业招聘等企业级服务. 阿里钉钉的商旅服务提供方是阿里旅行,钉钉创始人陈航现场演示了钉钉上如何利用"阿里商旅"订机票.酒店.演示界面显示,商旅预订的界面和阿里旅行的预订界面非常

钉钉企业微应用的接入

钉钉企业微应用的基本接入 对于初次接触钉钉,在看完官方文档后,接入钉钉企业微应用的同学.在看文档的过程当中可能有点迷糊. 在钉钉官方文档中,前端和后端是分开来的.没有整体讲解到钉钉前端与后端的结合. 今天主要讲解一下如何简单的接入钉钉企业的微应用以及获取免登码. 先看一下企业流程图 1. 准备工作 1.注册钉钉企业账号,并创建钉钉企业.这里我就不复述了.官方文档已经给出具体的操作步骤 2.新建一个微应用. 2.开发  搭建自己的本地或者部署在外网web服务 设置首页地址为本地服务的地址或外网的服

钉钉接入微应用的企业应用中调用免登

因为公司接入了阿里的企业应用钉钉,简化了公司中一些日常的行政任务.因为业务需要,公司之前做了一个知识库的项目,是一个webapp,是基于微信的企业号应用的.后来为了简化业务,全部集成到钉钉上.所以,才有了钉钉中的微应用的免登陆问题. 钉钉的免登陆需要前后台合作才可以.因为钉钉有些从前端js接口请求会有跨域的问题,所以需要后台来解决这个问题.下面进入钉钉的免登流程. 进入钉钉的官方文档页面,找到免登文档. 钉钉的免登类型分为企业应用中调用免登.ISV应用中调用免登.普通钉钉用户账号开放及免登三种.

阿里钉钉开启企业服务 让冷冰冰的工作也可以有温度

10月11日,阿里钉钉宣布正式上线企业服务,为用户企业提供商旅.订餐.用车.健康.社保等方面的服务,助力企业更高效.更人性化地"开启有温度的工作". 出差不用垫资.不用贴发票报销 钉钉创始人陈航(花名无招)对工作的温度进行了阐述,钉钉一直追求高效和人性化的工作体验,此次携手合作伙伴打造的企业服务,不但能让钉钉用户企业的员工工作更高效,还能体会到人性化的工作方式,工作起来更有温度.更温暖. 据介绍,钉钉企业服务计划的筹备花费了3个多月时间,接入的服务内容不但包括基于工作场景的吃.住.行,

平台化多维延伸——阿里钉钉推“企业服务计划”渗透“人心”

阿里钉钉提出企业服务计划,在其开放平台首次接入商旅.订餐.用车.体检.法务等企业必不可少的服务,为企业用户提供和工作相关的全套服务.阿里钉钉深入到了服务"人"的层面,为企业员工的出行.用餐等提供便利.快捷.高品质的服务,相当于打开了一个新的维度. 2015年被称为企业级服务元年,这主要指的是SaaS服务,中国SaaS企业也在第一波涌向企业级服务的热钱的推动下奋勇开疆辟土,红红火火地过了一年.如今,走进创业深水区的SaaS企业,在最初在资本层面.产品.技术等的较量后,平台化生态布局如火如

钉钉再放大招!打造公司文化也有智能工具,中国4300万中小企业有福了

"截至2017年9月30日,钉钉企业组织数量超过500万家,成为全球最大的企业服务平台."钉钉CEO陈航(花名:"无招")在11月19日钉钉"智连未来"的发布会现场首先公布了钉钉的最新数据. 这是无招继6月份的"中国酷公司"发布会后再次登上深圳卫视的舞台. 发布会现场,无招表示,通过软件的全面升级与硬件的连接协同,钉钉将实现又一次飞跃. 钉钉4.0时代来临,他要帮助中国4300万中小企业跨入智能移动办公时代. 钉钉推出首款智能