天天看點

開放平台(ISV接入)1. 基本概念2. 第一步,注冊3. 第二步,被動回調處理4. 第三步,服務端主動調用5. 第四步,容器頁面與 jsapi

開發平台(ISV)系統架構示例圖 

開放平台(ISV接入)1. 基本概念2. 第一步,注冊3. 第二步,被動回調處理4. 第三步,服務端主動調用5. 第四步,容器頁面與 jsapi

标題

開放平台(ISV接入)1. 基本概念2. 第一步,注冊3. 第二步,被動回調處理4. 第三步,服務端主動調用5. 第四步,容器頁面與 jsapi

微應用建立步驟

  • 套件建立
  • 應用添加
  • 企業授權
  • 應用市場添加應用
  • 應用上架

重要參數:

套件KEY,套件加密串,回調位址

應用位址

永久授權碼,授權同步

suiteTicket

suiteToken

注:用這裡回調生成的suiteTicket的資料配合套件的Key和secret去取suiteToken

以釘釘ISV接入為例

1. 基本概念

相較于 Web 領域經常碰到的 OAuth2 這類簡單的三方授權模型,釘釘的 ISV 接入流程涉及的東西比較多一點,是以在正式開始之前,先把過程中會見到的各種概念拿出來說一下,同時,先用用釘釘吧。

1.1. 使用者user與企業corp

釘釘中的使用者與企業,算是一個開放式的關聯關系,通過手機号來辨別一個“唯一”的使用者。

一個使用者可以是多個企業的成員,在具體的操作頁面上,可以切換到不同企業,進而看到不同企業中可能有不同的應用。

開放平台(ISV接入)1. 基本概念2. 第一步,注冊3. 第二步,被動回調處理4. 第三步,服務端主動調用5. 第四步,容器頁面與 jsapi

與使用者相關的關鍵資料有: userId 和 jobnumber , userId 是使用釘釘相關 api 時的使用者辨別, jobnumber 是使用者在釘釘中,作為“員工”角色時的一個屬性,它一般是打通釘釘與企業内部其它系統的辨別。

與企業有關的關鍵資料,是幾個配置資料, corp_id , corp_secret 這兩個是換 token 時會用到的(ISV 流程中用不到)。還有一個 sso_secret ,在作背景直接登入時會用到(這個功能不是非實作不可)。

使用者的角色,除了是某個企業的“員工”之後,還可以是企業的“管理者”,或是“主管理者”。具體企業的管理背景,是通過 https://oa.dingtalk.com 單獨登入的。

1.2. 應用agent與套件suite

“應用”就是在具體的企業頁面,預設九宮格裡一個一個的圖示,點選之後會跳轉到特定的頁面。

“套件”是多個應用,視覺上在九宮格中它們會收在一起,點選之後再在一個彈出框中展開,就像 iOS 上的那個“附加程式”。

開放平台(ISV接入)1. 基本概念2. 第一步,注冊3. 第二步,被動回調處理4. 第三步,服務端主動調用5. 第四步,容器頁面與 jsapi

“套件”是對 ISV 才有的概念,因為釘釘設計的 ISV 接入規則中, ISV 就是以“套件”為機關來提供“産品輸出”的。在建立了一個套件之後,可以在裡面建立多個應用,而且按目前來看,授權也是以套件為機關,但是企業的管理者可以停用其中的某一個應用(這裡釘釘的背景在互動上還有一個 BUG,2016-5-20)。

開放平台(ISV接入)1. 基本概念2. 第一步,注冊3. 第二步,被動回調處理4. 第三步,服務端主動調用5. 第四步,容器頁面與 jsapi

與應用有關的資料,是 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/ ,然後建立一個釘釘的套件。這個過程可能需要附加阿裡雲的開發者認證,但是事實上它跟阿裡雲完全沒關系的。

開放平台(ISV接入)1. 基本概念2. 第一步,注冊3. 第二步,被動回調處理4. 第三步,服務端主動調用5. 第四步,容器頁面與 jsapi

好吧,如果你最後已經走到“建立套件”這裡了,那麼就可以了,不出意外的話,你是沒法簡單地把一個套件成功建立的,因為那個“回調URL”需要被即時檢查,并通過,這就是下面我們要講到的内容。

開放平台(ISV接入)1. 基本概念2. 第一步,注冊3. 第二步,被動回調處理4. 第三步,服務端主動調用5. 第四步,容器頁面與 jsapi

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_code

suite_key

,

auth_corpid

企業基本資訊,包括套件中的各應用在這個企業的

agent

 資訊
擷取企業中的本套件中的具體應用的狀态

/get_agent

suite_access_token

,

permanent_code

suite_key

,

auth_corpid

agentid

應用資訊,包括是否“激活”的狀态
激活某企業中的套件

/activate_suite

suite_access_token

,

permanent_code

suite_key

,

auth_corpid

(無)
擷取企業的corp_access_token

/get_corp_token

suite_access_token

,

permanent_code

auth_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>
           

繼續閱讀