關於釘釘
釘釘是阿里推出的企業移動OA平台,本身提供了豐富的通用應用,同時其強大的后台API接入能力讓企業接入自主開發的應用成為可能,可以讓開發者實現幾乎任何需要的功能。
近期因為工作需要研究了一下釘釘的接入,發現其接入文檔、SDK都是基於java編寫的,而我們的企業網站使用Asp.Net MVC(C#)開發,所以接入只能從頭自己做SDK。
接入主要包括免登、獲取數據、修改數據等接口。
免登流程
首先需要理解一下釘釘的免登流程,借用官方文檔的圖片:
是不是很熟悉?是的,基本是按照OAUTH的原理來的,版本嘛,里面有計算簽名的部分,我覺得應該是OAUTH1.0。
有的讀者會問,那第一步是不是應該跳轉到第三方認證頁面啊。我覺得“魔法”就藏在用來打開頁面的釘釘內置瀏覽器里,在dd.config()這一步里,“魔法”就生效了。
其實簡單來說,主要分為五步:
- 在你的Web服務器端調用api,傳入CorpId和CorpSecret,獲取accessToken,即訪問令牌。
- 在服務器端調用api,傳入accessToken,獲取JsApiTicket,即JsApi的訪問許可(門票)。
- 按照既定規則,在后台由JsApiTicket、NonceStr、Timestamp、本頁面Url生成字符串,計算SHA1消息摘要,即簽名Signature。
- 將AgentId、CorpId、Timestamp、NonceStr、Signature等參數傳遞到前台,在前台調用api,得到authCode,即授權碼。
- 根據授權碼,在前台或后台調用api,獲得userId,進而再根據userId,調用api獲取用戶詳細信息。
PS:為什么需要在后台完成一些api的調用呢?應該是因為js跨域調用的問題,我具體沒有深究。
實踐方法
理解了上述步驟,我對登陸過程的實現也大致有了一個設想,既然免登需要前后端一起來完成,那就添加一個專門的登陸頁面,將登陸過程都在里面實現,將登陸結果寫入到Session,並重定向回業務頁面,即算完成。圖示如下:
其中每個api的調用方式,在官方文檔中都有說明。同時,我在阿里雲開發者論壇找到了網友提供的SDK,有興趣可以下載:釘釘非官方.Net SDK
另外,GitHub上還有官方的JQuery版免登開發Demo,可以參考:GitHub JQuery免登。
我參考的是.Net SDK,將其中的代碼,提取出了我所需要的部分,做了簡化處理。基本原理就是每次調用API都是發起HttpRequest,將結果做JSON反序列化。
核心代碼如下:
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Web; 5 using System.IO; 6 using Newtonsoft.Json; 7 using Newtonsoft.Json.Linq; 8 using DDApi.Model; 9 10 namespace DDApi 11 { 12 public static class DDHelper 13 { 14 public static string GetAccessToken(string corpId, string corpSecret) 15 { 16 string url = string.Format("https://oapi.dingtalk.com/gettoken?corpid={0}&corpsecret={1}", corpId, corpSecret); 17 try 18 { 19 string response = HttpRequestHelper.Get(url); 20 AccessTokenModel oat = Newtonsoft.Json.JsonConvert.DeserializeObject<AccessTokenModel>(response); 21 22 if (oat != null) 23 { 24 if (oat.errcode == 0) 25 { 26 return oat.access_token; 27 } 28 } 29 } 30 catch (Exception ex) 31 { 32 throw; 33 } 34 return string.Empty; 35 } 36 37 /* https://oapi.dingtalk.com/get_jsapi_ticket?access_token=79721ed2fc46317197e27d9bedec0425 38 * 39 * errmsg "ok" 40 * ticket "KJWkoWOZ0BMYaQzWFDF5AUclJOHgO6WvzmNNJTswpAMPh3S2Z98PaaJkRzkjsmT5HaYFfNkMdg8lFkvxSy9X01" 41 * expires_in 7200 42 * errcode 0 43 */ 44 public static string GetJsApiTicket(string accessToken) 45 { 46 string url = string.Format("https://oapi.dingtalk.com/get_jsapi_ticket?access_token={0}", accessToken); 47 try 48 { 49 string response = HttpRequestHelper.Get(url); 50 JsApiTicketModel model = Newtonsoft.Json.JsonConvert.DeserializeObject<JsApiTicketModel>(response); 51 52 if (model != null) 53 { 54 if (model.errcode == 0) 55 { 56 return model.ticket; 57 } 58 } 59 } 60 catch (Exception ex) 61 { 62 throw; 63 } 64 return string.Empty; 65 } 66 67 public static long GetTimeStamp() 68 { 69 TimeSpan ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0); 70 return Convert.ToInt64(ts.TotalSeconds); 71 } 72 73 public static string GetUserId(string accessToken, string code) 74 { 75 string url = string.Format("https://oapi.dingtalk.com/user/getuserinfo?access_token={0}&code={1}", accessToken, code); 76 try 77 { 78 string response = HttpRequestHelper.Get(url); 79 GetUserInfoModel model = Newtonsoft.Json.JsonConvert.DeserializeObject<GetUserInfoModel>(response); 80 81 if (model != null) 82 { 83 if (model.errcode == 0) 84 { 85 return model.userid; 86 } 87 else 88 { 89 throw new Exception(model.errmsg); 90 } 91 } 92 } 93 catch (Exception ex) 94 { 95 throw; 96 } 97 return string.Empty; 98 } 99 100 public static string GetUserDetailJson(string accessToken, string userId) 101 { 102 string url = string.Format("https://oapi.dingtalk.com/user/get?access_token={0}&userid={1}", accessToken, userId); 103 try 104 { 105 string response = HttpRequestHelper.Get(url); 106 return response; 107 } 108 catch (Exception ex) 109 { 110 throw; 111 } 112 return null; 113 } 114 115 public static UserDetailInfo GetUserDetail(string accessToken, string userId) 116 { 117 string url = string.Format("https://oapi.dingtalk.com/user/get?access_token={0}&userid={1}", accessToken, userId); 118 try 119 { 120 string response = HttpRequestHelper.Get(url); 121 UserDetailInfo model = Newtonsoft.Json.JsonConvert.DeserializeObject<UserDetailInfo>(response); 122 123 if (model != null) 124 { 125 if (model.errcode == 0) 126 { 127 return model; 128 } 129 } 130 } 131 catch (Exception ex) 132 { 133 throw; 134 } 135 return null; 136 } 137 138 public static List<DepartmentInfo> GetDepartmentList(string accessToken, int parentId = 1) 139 { 140 string url = string.Format("https://oapi.dingtalk.com/department/list?access_token={0}", accessToken); 141 if (parentId >= 0) 142 { 143 url += string.Format("&id={0}", parentId); 144 } 145 try 146 { 147 string response = HttpRequestHelper.Get(url); 148 GetDepartmentListModel model = Newtonsoft.Json.JsonConvert.DeserializeObject<GetDepartmentListModel>(response); 149 150 if (model != null) 151 { 152 if (model.errcode == 0) 153 { 154 return model.department.ToList(); 155 } 156 } 157 } 158 catch (Exception ex) 159 { 160 throw; 161 } 162 return null; 163 } 164 } 165 }

1 using System.IO; 2 using System.Net; 3 4 namespace DDApi 5 { 6 public class HttpRequestHelper 7 { 8 public static string Get(string url) 9 { 10 WebRequest request = HttpWebRequest.Create(url); 11 WebResponse response = request.GetResponse(); 12 Stream stream = response.GetResponseStream(); 13 StreamReader reader = new StreamReader(stream); 14 string content = reader.ReadToEnd(); 15 return content; 16 } 17 18 public static string Post(string url) 19 { 20 WebRequest request = HttpWebRequest.Create(url); 21 request.Method = "POST"; 22 WebResponse response = request.GetResponse(); 23 Stream stream = response.GetResponseStream(); 24 StreamReader reader = new StreamReader(stream); 25 string content = reader.ReadToEnd(); 26 return content; 27 } 28 } 29 }
其中的Model,就不再一一貼出來了,大家可以根據官方文檔自己建立,這里只舉一個例子,即GetAccessToken的返回結果:
public class AccessTokenModel { public string access_token { get; set; } public int errcode { get; set; } public string errmsg { get; set; } }
我創建了一個類DDApiService,將上述方法做了封裝:

using DDApi.Model; using System; using System.Collections.Generic; using System.Configuration; using System.Security.Cryptography; using System.Text; namespace DDApi { /// <summary> /// /// </summary> public class DDApiService { public static readonly DDApiService Instance = new DDApiService(); public string CorpId { get; private set; } public string CorpSecret { get; private set; } public string AgentId { get; private set; } private DDApiService() { CorpId = ConfigurationManager.AppSettings["corpId"]; CorpSecret = ConfigurationManager.AppSettings["corpSecret"]; AgentId = ConfigurationManager.AppSettings["agentId"]; } /// <summary> /// 獲取AccessToken /// 開發者在調用開放平台接口前需要通過CorpID和CorpSecret獲取AccessToken。 /// </summary> /// <returns></returns> public string GetAccessToken() { return DDHelper.GetAccessToken(CorpId, CorpSecret); } public string GetJsApiTicket(string accessToken) { return DDHelper.GetJsApiTicket(accessToken); } public string GetUserId(string accessToken, string code) { return DDHelper.GetUserId(accessToken, code); } public UserDetailInfo GetUserDetail(string accessToken, string userId) { return DDHelper.GetUserDetail(accessToken, userId); } public string GetUserDetailJson(string accessToken, string userId) { return DDHelper.GetUserDetailJson(accessToken, userId); } public UserDetailInfo GetUserDetailFromJson(string jsonString) { UserDetailInfo model = Newtonsoft.Json.JsonConvert.DeserializeObject<UserDetailInfo>(jsonString); if (model != null) { if (model.errcode == 0) { return model; } } return null; } public string GetSign(string ticket, string nonceStr, long timeStamp, string url) { String plain = string.Format("jsapi_ticket={0}&noncestr={1}×tamp={2}&url={3}", ticket, nonceStr, timeStamp, url); try { byte[] bytes = Encoding.UTF8.GetBytes(plain); byte[] digest = SHA1.Create().ComputeHash(bytes); string digestBytesString = BitConverter.ToString(digest).Replace("-", ""); return digestBytesString.ToLower(); } catch (Exception e) { throw; } } public List<DepartmentInfo> GetDepartmentList(string accessToken, int parentId = 1) { return DDHelper.GetDepartmentList(accessToken, parentId); } } }
以上是底層核心部分。登錄頁面的實現在控制器DDController中,代碼如下:

using DDApi; using DDApi.Model; using System; using System.Web.Mvc; namespace AppointmentWebApp.Controllers { public class DDController : Controller { // // GET: /DD/ public ActionResult GetUserInfo(string accessToken, string code, bool setCurrentUser = true) { try { string userId = DDApiService.Instance.GetUserId(accessToken, code); string jsonString = DDApiService.Instance.GetUserDetailJson(accessToken, userId); UserDetailInfo userInfo = DDApiService.Instance.GetUserDetailFromJson(jsonString); if (setCurrentUser) { Session["AccessToken"] = accessToken; Session["CurrentUser"] = userInfo; } return Content(jsonString); } catch (Exception ex) { return Content(string.Format("{{'errcode': -1, 'errmsg':'{0}'}}", ex.Message)); } } public ActionResult Login() { BeginDDAutoLogin(); return View(); } private void BeginDDAutoLogin() { string nonceStr = "helloDD";//todo:隨機 ViewBag.NonceStr = nonceStr; string accessToken = DDApiService.Instance.GetAccessToken(); ViewBag.AccessToken = accessToken; string ticket = DDApiService.Instance.GetJsApiTicket(accessToken); long timeStamp = DDHelper.GetTimeStamp(); string url = Request.Url.ToString(); string signature = DDApiService.Instance.GetSign(ticket, nonceStr, timeStamp, url); ViewBag.JsApiTicket = ticket; ViewBag.Signature = signature; ViewBag.NonceStr = nonceStr; ViewBag.TimeStamp = timeStamp; ViewBag.CorpId = DDApiService.Instance.CorpId; ViewBag.CorpSecret = DDApiService.Instance.CorpSecret; ViewBag.AgentId = DDApiService.Instance.AgentId; } } }
視圖View的代碼:

@{ ViewBag.Title = "Login"; } <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width" /> <title>@ViewBag.Title</title> </head> <body> <h2 id="notice">正在登錄...</h2> <script src="//cdn.bootcss.com/jquery/1.12.4/jquery.min.js"></script> <script type="text/javascript" src="http://g.alicdn.com/dingding/open-develop/1.5.1/dingtalk.js"></script> <script type="text/javascript"> var _config = []; _config.agentId = "@ViewBag.AgentId"; _config.corpId = "@ViewBag.CorpId"; _config.timeStamp = "@ViewBag.TimeStamp"; _config.nonceStr = "@ViewBag.NonceStr"; _config.signature = "@ViewBag.Signature"; dd.config({ agentId: _config.agentId, corpId: _config.corpId, timeStamp: _config.timeStamp, nonceStr: _config.nonceStr, signature: _config.signature, jsApiList: ['runtime.info', 'biz.contact.choose', 'device.notification.confirm', 'device.notification.alert', 'device.notification.prompt', 'biz.ding.post', 'biz.util.openLink'] }); dd.ready(function () { dd.runtime.info({ onSuccess: function (info) { logger.e('runtime info: ' + JSON.stringify(info)); }, onFail: function (err) { logger.e('fail: ' + JSON.stringify(err)); } }); dd.runtime.permission.requestAuthCode({ corpId: _config.corpId, onSuccess: function (info) {//成功獲得code值,code值在info中 //alert('authcode: ' + info.code); //alert('token: @ViewBag.AccessToken'); /* *$.ajax的是用來使得當前js頁面和后台服務器交互的方法 *參數url:是需要交互的后台服務器處理代碼,這里的userinfo對應WEB-INF -> classes文件中的UserInfoServlet處理程序 *參數type:指定和后台交互的方法,因為后台servlet代碼中處理Get和post的doGet和doPost *原本需要傳輸的參數可以用data來存儲的,格式為data:{"code":info.code,"corpid":_config.corpid} *其中success方法和error方法是回調函數,分別表示成功交互后和交互失敗情況下處理的方法 */ $.ajax({ url: '@Url.Action("GetUserInfo", "DD")?code=' + info.code + '&accessToken=@ViewBag.AccessToken',//userinfo為本企業應用服務器后台處理程序 type: 'GET', /* *ajax中的success為請求得到相應后的回調函數,function(response,status,xhr) *response為響應的數據,status為請求狀態,xhr包含XMLHttpRequest對象 */ success: function (data, status, xhr) { alert(data); var info = JSON.parse(data); if (info.errcode != 0) { alert(data); } else { //alert("當前用戶:" + info.name); $('#notice').text("歡迎您:" + info.name + "。瀏覽器正在自動跳轉..."); location.href = "@Url.Action("Index", "Home")"; } }, error: function (xhr, errorType, error) { logger.e("嘗試獲取用戶信息失敗:" + info.code); alert(errorType + ', ' + error); } }); }, onFail: function (err) {//獲得code值失敗 alert('fail: ' + JSON.stringify(err)); } }); }); dd.error(function (err) { alert('dd error: ' + JSON.stringify(err)); }); </script> </body> </html>
其中nonstr理論上最好應該每次都隨機,留待讀者去完成吧:-)
釘釘免登就是這樣,只要弄懂了就會覺得其實不難,還順便理解了OAUTH。
后續改進
這個流程沒有考慮到AccessToken、JsApiTicket的有效期時間(2小時),因為整個過程就在一個頁面中都完成了。如果想要進一步擴展,多次調用api的話,需要考慮到上述有效期。
如果為了圖簡便每都去獲取AccessToken也是可以的,但是會增加服務器負擔,而且api的調用頻率是有限制的(1500次/s好像),所以應當采取措施控制。例如可以將AccessToken、JsApiTicket存放在this.HttpContext.Application["accessToken"]中,每次判斷有效期是否過期,如果過期就調用api重新申請一個。
以上就是這樣,感謝閱讀。
20170710編輯,更新mvc免登流程圖片,修正一處錯誤。