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

标題
微應用建立步驟
- 套件建立
- 應用添加
- 企業授權
- 應用市場添加應用
- 應用上架
重要參數:
套件KEY,套件加密串,回調位址
應用位址
永久授權碼,授權同步
suiteTicket
suiteToken
注:用這裡回調生成的suiteTicket的資料配合套件的Key和secret去取suiteToken
以釘釘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×tamp=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 的有效性,建立套件時發生 | 響應得到的 值 |
suite_ticket | 定時推送的 ticket 值,20 分鐘一次 | 記錄 ticket 值,響應 字元串 |
tmp_auth_code | 推送臨時授權碼,在套件頁面添加測試企業時發生(真實環境不知道何時發生,可能是企業添加應用時?) | 記錄 AuthCode值,響應 字元串 |
change_auth | 推送授權變更消息,比如禁用啟用應用時 | 檢查各應用狀态,響應 字元串 |
check_update_suite_url | 套件資訊更新,套件資訊改變時發生 | 響應得到的 值 |
suite_relieve | 企業解除授權 | 清理相關資料,響應 字元串 |
check_suite_license_code | 不知道什麼時候會用到 | 成功的話響應 字元串 |
雖然 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 | | , , | |
擷取企業的永久授權碼 | | , | |
擷取企業的基本資訊 | | , , , | 企業基本資訊,包括套件中的各應用在這個企業的 資訊 |
擷取企業中的本套件中的具體應用的狀态 | | , , , , | 應用資訊,包括是否“激活”的狀态 |
激活某企業中的套件 | | , , , | (無) |
擷取企業的corp_access_token | | , , | |
說一下上面每一個操作拿到的東西都有什麼用:
-
, ISV 行為的所有 api 都需要用它。suite_access_token
-
,以 ISV 角色去擷取指定企業的相關資訊需要用它(直到拿到企業的permanent_code
)。corp_access_token
-
,這東西的作用跟“微應用”方式下我們自己取得的corp_access_token
是一樣的,用它就可以直接去使用釘釘的其它 api 了。access_token
看了上面的清單,是不是特别想吐槽那重複的“請求參數”,本來嘛,一個
suite_access_token
中其實已經包含了
suite_key
這個資訊。同理,
permanent_code
中也是包含了
auth_corpid
資訊。那麼
suite_access_token
+
permanent_code
已經表達清楚了我是哪個套件,要針對哪個企業進行操作。
再補充說幾點:
- 至少在測試企業中,隻是在套件背景添加了一個測試企業的話,在測試企業的管理背景還是看不到套件應用的。需要“激活”之後,才能在企業的背景看到添加的套件應用,并進行配置,或者停用其中的某些應用。
-
是動态變化的,釘釘的伺服器會通過“回調位址”定時主動推送,需要應用自己儲存。suite_ticket
-
和suite_access_token
在調用 api 時會頻繁用到,在擷取時釘釘服務會一并響應一個“有效期”,在有效期内corp_access_token
是可以重複使用的,是以應用系統應該自己緩存并維護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>